2026-01-27 19:56:26 +00:00
package text
2026-01-26 20:29:04 +00:00
import (
"context"
"fmt"
"strings"
2026-01-26 21:21:21 +00:00
"time"
2026-01-26 20:29:04 +00:00
2026-01-27 18:44:02 +00:00
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
2026-01-26 20:29:04 +00:00
"github.com/Gleipnir-Technology/nidus-sync/comms/text"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db"
2026-01-26 21:21:21 +00:00
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
2026-01-26 20:29:04 +00:00
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/db/sql"
"github.com/Gleipnir-Technology/nidus-sync/llm"
2026-01-26 21:21:21 +00:00
"github.com/aarondl/opt/omit"
2026-01-26 21:11:31 +00:00
"github.com/aarondl/opt/omitnull"
2026-01-26 21:21:21 +00:00
"github.com/nyaruka/phonenumbers"
2026-01-26 20:29:04 +00:00
"github.com/rs/zerolog/log"
)
2026-01-27 19:56:26 +00:00
type E164 = phonenumbers . PhoneNumber
2026-01-27 14:29:55 +00:00
func HandleTextMessage ( from string , to string , body string ) {
ctx := context . Background ( )
type_ , src := splitPhoneSource ( from )
dst , err := getDst ( ctx , to )
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "to" , to ) . Msg ( "Failed to get dst" )
return
}
2026-01-27 19:56:26 +00:00
_ , err = insertTextLog ( ctx , body , dst , src , enums . CommsTextoriginCustomer , false , true )
2026-01-27 14:29:55 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "dst" , dst ) . Msg ( "Failed to add text message log" )
return
}
subscribed , err := isSubscribed ( ctx , src )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to handle message" )
return
}
2026-01-27 18:44:02 +00:00
body_l := strings . TrimSpace ( strings . ToLower ( body ) )
2026-01-27 14:29:55 +00:00
// We don't know if they're subscribed or not.
if subscribed == nil {
switch body_l {
case "yes" :
setSubscribed ( ctx , src , true )
handleWaitingTextJobs ( ctx , src )
default :
content := "I have to start with either 'YES' or 'STOP' first, Which do you want?"
2026-01-27 19:56:26 +00:00
err = sendText ( ctx , dst , src , content , enums . CommsTextoriginReiteration , false , false )
2026-01-27 14:29:55 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to resend initial prompt." )
}
}
return
}
2026-01-29 21:53:49 +00:00
switch body_l {
case "stop" :
content := "You have successfully been unsubscribed. You will not receive any more messages from this number. Reply START to resubscribe."
err = sendText ( ctx , dst , src , content , enums . CommsTextoriginCommandResponse , false , false )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to send unsubscribe acknowledgement." )
}
setSubscribed ( ctx , src , false )
return
case "reset conversation" :
2026-01-27 18:44:02 +00:00
handleResetConversation ( ctx , src , dst )
return
2026-01-29 21:53:49 +00:00
default :
2026-01-27 18:44:02 +00:00
}
previous_messages , err := loadPreviousMessagesForLLM ( ctx , dst , src )
2026-01-27 14:29:55 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "dst" , dst ) . Str ( "src" , from ) . Msg ( "Failed to get previous messages" )
return
}
log . Info ( ) . Int ( "len" , len ( previous_messages ) ) . Msg ( "passing" )
2026-01-27 18:44:02 +00:00
next_message , err := generateNextMessage ( ctx , previous_messages , src )
2026-01-27 14:29:55 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "dst" , dst ) . Str ( "src" , from ) . Msg ( "Failed to generate next message" )
return
}
2026-01-27 19:56:26 +00:00
err = sendText ( ctx , dst , src , next_message . Content , enums . CommsTextoriginLLM , false , true )
2026-01-27 14:29:55 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Str ( "content" , next_message . Content ) . Msg ( "Failed to send response text" )
return
}
log . Info ( ) . Str ( "from" , from ) . Str ( "from-type" , type_ ) . Str ( "to" , to ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Str ( "body" , body ) . Str ( "reply" , next_message . Content ) . Msg ( "Handled text message" )
}
2026-01-27 19:56:26 +00:00
func ParsePhoneNumber ( input string ) ( * E164 , error ) {
return phonenumbers . Parse ( input , "US" )
}
func StoreSources ( ) error {
2026-01-26 21:21:21 +00:00
ctx := context . TODO ( )
2026-01-29 21:53:49 +00:00
for _ , n := range [ ] string { config . PhoneNumberReportStr , config . PhoneNumberSupportStr , config . VoipMSNumber } {
err := ensureInDB ( ctx , n )
if err != nil {
return fmt . Errorf ( "Failed to add number '%s' to DB: %w" , n , err )
}
}
return nil
2026-01-26 21:21:21 +00:00
}
2026-01-27 14:29:55 +00:00
func UpdateMessageStatus ( twilio_sid string , status string ) {
ctx := context . TODO ( )
l , err := models . CommsTextLogs . Query (
models . SelectWhere . CommsTextLogs . TwilioSid . EQ ( twilio_sid ) ,
) . One ( ctx , db . PGInstance . BobDB )
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "twilio_sid" , twilio_sid ) . Str ( "status" , status ) . Msg ( "Failed to update message status query failed" )
return
}
err = l . Update ( ctx , db . PGInstance . BobDB , & models . CommsTextLogSetter {
TwilioStatus : omit . From ( status ) ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "twilio_sid" , twilio_sid ) . Str ( "status" , status ) . Msg ( "Failed to update message status update failed" )
return
}
}
2026-01-26 21:21:21 +00:00
func delayMessage ( ctx context . Context , source string , destination string , content string , type_ enums . CommsTextjobtype ) error {
job , err := models . CommsTextJobs . Insert ( & models . CommsTextJobSetter {
Content : omit . From ( content ) ,
Created : omit . From ( time . Now ( ) ) ,
Destination : omit . From ( destination ) ,
//ID:
Type : omit . From ( type_ ) ,
} ) . One ( ctx , db . PGInstance . BobDB )
if err != nil {
return fmt . Errorf ( "Failed to add delayed text job: %w" , err )
}
log . Info ( ) . Int32 ( "id" , job . ID ) . Msg ( "Created delayed text job" )
return nil
}
2026-01-27 19:56:26 +00:00
func resendInitialText ( ctx context . Context , src string , dst string ) error {
phone , err := models . FindCommsPhone ( ctx , db . PGInstance . BobDB , dst )
if err != nil {
return fmt . Errorf ( "Failed to find phone %s: %w" , dst , err )
}
err = phone . Update ( ctx , db . PGInstance . BobDB , & models . CommsPhoneSetter {
IsSubscribed : omitnull . FromPtr [ bool ] ( nil ) ,
} )
if err != nil {
return fmt . Errorf ( "Failed to clear subscription on phone %s: %w" , dst , err )
}
return nil
}
func sendInitialText ( ctx context . Context , src string , dst string ) error {
content := "Welcome to Report Mosquitoes Online. We received your request and want to confirm text updates. Reply YES to continue. Reply STOP at any time to unsubscribe"
origin := enums . CommsTextoriginWebsiteAction
err := sendText ( ctx , src , dst , content , origin , true , true )
if err != nil {
return fmt . Errorf ( "Failed to send initial confirmation: %w" , err )
}
return nil
}
2026-01-26 21:21:21 +00:00
func ensureInitialText ( ctx context . Context , src string , dst string ) error {
//
rows , err := models . CommsTextLogs . Query (
models . SelectWhere . CommsTextLogs . Destination . EQ ( dst ) ,
models . SelectWhere . CommsTextLogs . IsWelcome . EQ ( true ) ,
) . All ( ctx , db . PGInstance . BobDB )
if err != nil {
return fmt . Errorf ( "Failed to query text logs: %w" , err )
}
if len ( rows ) > 0 {
return nil
}
2026-01-27 19:56:26 +00:00
return sendInitialText ( ctx , src , dst )
2026-01-26 21:21:21 +00:00
}
func ensureInDB ( ctx context . Context , destination string ) ( err error ) {
_ , err = models . FindCommsPhone ( ctx , db . PGInstance . BobDB , destination )
if err != nil {
// doesn't exist
if err . Error ( ) == "sql: no rows in result set" {
_ , err = models . CommsPhones . Insert ( & models . CommsPhoneSetter {
E164 : omit . From ( destination ) ,
IsSubscribed : omitnull . FromPtr [ bool ] ( nil ) ,
} ) . One ( ctx , db . PGInstance . BobDB )
if err != nil {
return fmt . Errorf ( "Failed to insert new phone contact: %w" , err )
}
log . Info ( ) . Str ( "phone" , destination ) . Msg ( "Added text to the comms database" )
return nil
}
return fmt . Errorf ( "Unexpected error searching for phone contact: %w" , err )
}
return nil
}
2026-01-27 18:44:02 +00:00
func generateNextMessage ( ctx context . Context , history [ ] llm . Message , customer_phone string ) ( llm . Message , error ) {
_handle_report_status := func ( ) ( string , error ) {
2026-01-27 23:25:51 +00:00
return "Report: ABCD-1234-5678, District: Delta MVCD, Status: scheduled, Appointment: Wednesday 3:30pm" , nil
2026-01-27 18:44:02 +00:00
}
_handle_contact_district := func ( reason string ) {
log . Warn ( ) . Str ( "reason" , reason ) . Msg ( "Contacting district" )
}
_handle_contact_supervisor := func ( reason string ) {
log . Warn ( ) . Str ( "reason" , reason ) . Msg ( "Contacting supervisor" )
}
return llm . GenerateNextMessage ( ctx , history , _handle_report_status , _handle_contact_district , _handle_contact_supervisor )
}
2026-01-26 20:29:04 +00:00
// Translate from Twilio's representation of a RCS message sender to our concept of a phone number
// From: rcs:dev_report_mosquitoes_online_dosrvwxm_agent
// To: +16235525879
func getDst ( ctx context . Context , to string ) ( string , error ) {
if to == config . TwilioRCSSenderRMO {
return config . PhoneNumberReportStr , nil
}
/ *
phone , err := models . FindCommsPhone ( ctx , db . PGInstance . BobDB , to )
if err != nil {
return "" , fmt . Errorf ( "Failed to search for dest phone %s: %w" , to , err )
}
return phone . E164 , nil
* /
return "" , fmt . Errorf ( "Cannot match phone number to '%s'" , to )
}
2026-01-27 14:29:55 +00:00
func handleWaitingTextJobs ( ctx context . Context , src string ) {
log . Info ( ) . Str ( "src" , src ) . Msg ( "Pretend handle waiting jobs" )
}
2026-01-27 18:44:02 +00:00
func handleResetConversation ( ctx context . Context , src string , dst string ) {
err := wipeLLMMemory ( ctx , src , dst )
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Msg ( "Failed to wipe memory" )
content := "Failed to wip memory"
2026-01-27 19:56:26 +00:00
err = sendText ( ctx , dst , src , content , enums . CommsTextoriginCommandResponse , false , false )
2026-01-27 18:44:02 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Msg ( "Failed to indicated memory wipe failure." )
}
return
}
content := "LLM memory wiped"
2026-01-27 19:56:26 +00:00
err = sendText ( ctx , dst , src , content , enums . CommsTextoriginCommandResponse , false , false )
2026-01-27 18:44:02 +00:00
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Msg ( "Failed to indicated memory wiped." )
return
}
log . Info ( ) . Err ( err ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Msg ( "Wiped LLM memory" )
}
2026-01-27 14:29:55 +00:00
2026-01-27 19:56:26 +00:00
func insertTextLog ( ctx context . Context , content string , destination string , source string , origin enums . CommsTextorigin , is_welcome bool , is_visible_to_llm bool ) ( log * models . CommsTextLog , err error ) {
2026-01-27 14:29:55 +00:00
log , err = models . CommsTextLogs . Insert ( & models . CommsTextLogSetter {
//ID:
2026-01-27 18:44:02 +00:00
Content : omit . From ( content ) ,
Created : omit . From ( time . Now ( ) ) ,
Destination : omit . From ( destination ) ,
2026-01-27 19:56:26 +00:00
IsVisibleToLLM : omit . From ( is_visible_to_llm ) ,
2026-01-27 18:44:02 +00:00
IsWelcome : omit . From ( is_welcome ) ,
Origin : omit . From ( origin ) ,
Source : omit . From ( source ) ,
TwilioSid : omitnull . FromPtr [ string ] ( nil ) ,
TwilioStatus : omit . From ( "" ) ,
2026-01-27 14:29:55 +00:00
} ) . One ( ctx , db . PGInstance . BobDB )
return log , err
}
func isSubscribed ( ctx context . Context , src string ) ( * bool , error ) {
phone , err := models . FindCommsPhone ( ctx , db . PGInstance . BobDB , src )
if err != nil {
return nil , fmt . Errorf ( "Failed to determine if '%s' is subscribed: %w" , src , err )
}
if phone . IsSubscribed . IsNull ( ) {
return nil , nil
}
result := phone . IsSubscribed . MustGet ( )
return & result , nil
}
2026-01-27 18:44:02 +00:00
func loadPreviousMessagesForLLM ( ctx context . Context , dst , src string ) ( [ ] llm . Message , error ) {
2026-01-26 20:29:04 +00:00
messages , err := sql . TextsBySenders ( dst , src ) . All ( ctx , db . PGInstance . BobDB )
results := make ( [ ] llm . Message , 0 )
if err != nil {
return results , fmt . Errorf ( "Failed to get message history for %s and %s: %w" , dst , src , err )
}
for _ , m := range messages {
2026-01-27 18:44:02 +00:00
if m . IsVisibleToLLM {
is_from_customer := ( m . Source == src )
results = append ( results , llm . Message {
IsFromCustomer : is_from_customer ,
Content : m . Content ,
} )
}
2026-01-26 20:29:04 +00:00
}
return results , nil
}
2026-01-27 19:56:26 +00:00
func sendText ( ctx context . Context , source string , destination string , message string , origin enums . CommsTextorigin , is_welcome bool , is_visible_to_llm bool ) error {
2026-01-26 21:21:21 +00:00
err := ensureInDB ( ctx , destination )
if err != nil {
return fmt . Errorf ( "Failed to ensure text message destination is in the DB: %w" , err )
}
2026-01-27 19:56:26 +00:00
l , err := insertTextLog ( ctx , message , destination , source , origin , is_welcome , is_visible_to_llm )
2026-01-26 21:21:21 +00:00
if err != nil {
return fmt . Errorf ( "Failed to insert text message in the DB: %w" , err )
}
2026-01-27 14:29:55 +00:00
sid , err := text . SendText ( ctx , source , destination , message )
if err != nil {
return fmt . Errorf ( "Failed to send text message: %w" , err )
}
2026-01-27 19:56:26 +00:00
err = l . Update ( ctx , db . PGInstance . BobDB , & models . CommsTextLogSetter {
2026-01-27 14:29:55 +00:00
TwilioSid : omitnull . From ( sid ) ,
TwilioStatus : omit . From ( "created" ) ,
} )
2026-01-27 19:56:26 +00:00
if err != nil {
return fmt . Errorf ( "Failed to update text Twilio status: %w" , err )
}
log . Info ( ) . Int32 ( "id" , l . ID ) . Bool ( "is_visible_to_llm" , is_visible_to_llm ) . Str ( "message" , message ) . Msg ( "inserted text log" )
2026-01-27 14:29:55 +00:00
2026-01-26 21:21:21 +00:00
return nil
}
2026-01-26 20:29:04 +00:00
func splitPhoneSource ( s string ) ( string , string ) {
parts := strings . Split ( s , ":" )
switch len ( parts ) {
case 0 :
return "this isn't" , "possible"
case 1 :
return "" , s
case 2 :
return parts [ 0 ] , parts [ 1 ]
default :
log . Warn ( ) . Str ( "s" , s ) . Msg ( "Got an incomprehensible number of parts of a phone number" )
return parts [ 0 ] , parts [ 1 ]
}
}
2026-01-26 20:51:26 +00:00
func setSubscribed ( ctx context . Context , src string , is_subscribed bool ) error {
phone , err := models . FindCommsPhone ( ctx , db . PGInstance . BobDB , src )
if err != nil {
return fmt . Errorf ( "Failed to determine if '%s' is subscribed: %w" , src , err )
}
phone . Update ( ctx , db . PGInstance . BobDB , & models . CommsPhoneSetter {
2026-01-26 21:11:31 +00:00
IsSubscribed : omitnull . From ( is_subscribed ) ,
2026-01-26 20:51:26 +00:00
} )
2026-01-26 21:11:31 +00:00
log . Info ( ) . Str ( "src" , src ) . Bool ( "is_subscribed" , is_subscribed ) . Msg ( "Set number subscribed" )
2026-01-26 20:51:26 +00:00
return nil
}
2026-01-27 18:44:02 +00:00
func wipeLLMMemory ( ctx context . Context , src string , dst string ) error {
rows , err := sql . TextsBySenders ( dst , src ) . All ( ctx , db . PGInstance . BobDB )
if err != nil {
return fmt . Errorf ( "Failed to query for texts: %w" , err )
}
ids := make ( [ ] int32 , 0 )
for _ , r := range rows {
ids = append ( ids , r . ID )
}
_ , err = models . CommsTextLogs . Update (
um . Where (
models . CommsTextLogs . Columns . ID . EQ ( psql . Any ( ids ) ) ,
) ,
um . SetCol ( "is_visible_to_llm" ) . ToArg ( false ) ,
) . Exec ( ctx , db . PGInstance . BobDB )
if err != nil {
return fmt . Errorf ( "Failed to update texts: %w" , err )
}
return nil
}