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-02-14 05:05:31 +00:00
"github.com/Gleipnir-Technology/bob"
2026-01-27 18:44:02 +00:00
"github.com/Gleipnir-Technology/bob/dialect/psql"
2026-02-14 17:04:36 +00:00
"github.com/Gleipnir-Technology/bob/dialect/psql/im"
2026-01-27 18:44:02 +00:00
"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-02-14 05:05:31 +00:00
func EnsureInDB ( ctx context . Context , ex bob . Executor , destination E164 ) ( err error ) {
return ensureInDB ( ctx , ex , PhoneString ( destination ) )
2026-01-31 20:57:34 +00:00
}
2026-01-29 22:36:16 +00:00
func HandleTextMessage ( src string , dst string , body string ) {
2026-01-27 14:29:55 +00:00
ctx := context . Background ( )
2026-01-29 22:36:16 +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
}
2026-01-31 20:08:08 +00:00
status , err := phoneStatus ( ctx , src )
2026-01-27 14:29:55 +00:00
if err != nil {
2026-01-31 20:08:08 +00:00
log . Error ( ) . Err ( err ) . Msg ( "Failed to get phone status" )
2026-01-27 14:29:55 +00:00
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.
2026-01-31 20:08:08 +00:00
if status == enums . CommsPhonestatustypeUnconfirmed {
2026-01-27 14:29:55 +00:00
switch body_l {
case "yes" :
2026-01-31 20:08:08 +00:00
setPhoneStatus ( ctx , src , enums . CommsPhonestatustypeOkToSend )
2026-01-31 20:16:12 +00:00
content := "Thanks, we've confirmed your phone number. You can text STOP at any time if you change your mind"
err := sendText ( ctx , dst , src , content , enums . CommsTextoriginCommandResponse , false , false )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to send confirmation response" )
}
2026-01-27 14:29:55 +00:00
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." )
}
2026-01-31 20:08:08 +00:00
setPhoneStatus ( ctx , src , enums . CommsPhonestatustypeStopped )
2026-01-29 21:53:49 +00:00
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 {
2026-01-29 22:36:16 +00:00
log . Error ( ) . Err ( err ) . Str ( "dst" , dst ) . Str ( "src" , src ) . Msg ( "Failed to get previous messages" )
2026-01-27 14:29:55 +00:00
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 {
2026-01-29 22:36:16 +00:00
log . Error ( ) . Err ( err ) . Str ( "dst" , dst ) . Str ( "src" , src ) . Msg ( "Failed to generate next message" )
2026-01-27 14:29:55 +00:00
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
}
2026-01-29 22:36:16 +00:00
log . Info ( ) . Str ( "src" , src ) . Str ( "dst" , dst ) . Str ( "body" , body ) . Str ( "reply" , next_message . Content ) . Msg ( "Handled text message" )
2026-01-27 14:29:55 +00:00
}
2026-01-27 19:56:26 +00:00
func ParsePhoneNumber ( input string ) ( * E164 , error ) {
return phonenumbers . Parse ( input , "US" )
}
2026-02-10 04:07:59 +00:00
func PhoneString ( p E164 ) string {
return phonenumbers . Format ( & p , phonenumbers . E164 )
}
2026-01-27 19:56:26 +00:00
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 } {
2026-01-29 22:27:51 +00:00
var err error
// Deal with Voip.ms not expecting API calls with the prefixed +1
if ! strings . HasPrefix ( n , "+1" ) {
2026-02-10 04:07:59 +00:00
dest , err := ParsePhoneNumber ( "+1" + n )
if err != nil {
return fmt . Errorf ( "Failed to parse +1'%s' as phone number: %w" , n , err )
}
2026-02-14 05:05:31 +00:00
err = EnsureInDB ( ctx , db . PGInstance . BobDB , * dest )
2026-01-29 22:27:51 +00:00
} else {
2026-02-10 04:07:59 +00:00
dest , err := ParsePhoneNumber ( n )
if err != nil {
return fmt . Errorf ( "Failed to parse '%s' as phone number: %w" , n , err )
}
2026-02-14 05:05:31 +00:00
err = EnsureInDB ( ctx , db . PGInstance . BobDB , * dest )
2026-01-29 22:27:51 +00:00
}
2026-01-29 21:53:49 +00:00
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-29 22:20:03 +00:00
func delayMessage ( ctx context . Context , source enums . CommsTextjobsource , destination string , content string , type_ enums . CommsTextjobtype ) error {
2026-01-26 21:21:21 +00:00
job , err := models . CommsTextJobs . Insert ( & models . CommsTextJobSetter {
Content : omit . From ( content ) ,
Created : omit . From ( time . Now ( ) ) ,
Destination : omit . From ( destination ) ,
//ID:
2026-01-29 22:20:03 +00:00
Source : omit . From ( source ) ,
Type : omit . From ( type_ ) ,
2026-01-26 21:21:21 +00:00
} ) . 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 {
2026-01-31 20:08:08 +00:00
Status : omit . From ( enums . CommsPhonestatustypeUnconfirmed ) ,
2026-01-27 19:56:26 +00:00
} )
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-02-14 05:05:31 +00:00
func ensureInDB ( ctx context . Context , ex bob . Executor , destination string ) ( err error ) {
2026-02-14 17:04:36 +00:00
_ , err = psql . Insert (
im . Into ( "comms.phone" , "e164" , "is_subscribed" , "status" ) ,
im . Values (
psql . Arg ( destination ) ,
psql . Arg ( false ) ,
psql . Arg ( "unconfirmed" ) ,
) ,
im . OnConflict ( "e164" ) . DoNothing ( ) ,
) . Exec ( ctx , ex )
return err
2026-01-26 21:21:21 +00:00
}
2026-02-10 04:07:59 +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
}
return sendInitialText ( ctx , src , dst )
}
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-27 14:29:55 +00:00
func handleWaitingTextJobs ( ctx context . Context , src string ) {
2026-01-29 22:20:03 +00:00
jobs , err := models . CommsTextJobs . Query (
models . SelectWhere . CommsTextJobs . Destination . EQ ( src ) ,
models . SelectWhere . CommsTextJobs . Completed . IsNull ( ) ,
) . All ( ctx , db . PGInstance . BobDB )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "Failed to query for jobs" )
return
}
for _ , job := range jobs {
var src string
switch job . Source {
case enums . CommsTextjobsourceRmo :
src = config . PhoneNumberReportStr
//case enums.CommsTextJobsourcenidus:
//src := config.PhoneNumebrNidusStr
default :
log . Error ( ) . Str ( "source" , job . Source . String ( ) ) . Msg ( "Can't support background text job." )
}
err = sendText ( ctx , src , job . Destination , job . Content , enums . CommsTextoriginWebsiteAction , false , true )
if err != nil {
log . Error ( ) . Err ( err ) . Int32 ( "id" , job . ID ) . Msg ( "Failed to send delayed text job." )
continue
}
err := job . Update ( ctx , db . PGInstance . BobDB , & models . CommsTextJobSetter {
Completed : omitnull . From ( time . Now ( ) ) ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Int32 ( "id" , job . ID ) . Msg ( "Failed to update delayed text job." )
continue
}
}
2026-01-27 14:29:55 +00:00
}
2026-01-29 22:20:03 +00:00
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
}
2026-01-31 20:08:08 +00:00
func phoneStatus ( ctx context . Context , src string ) ( enums . CommsPhonestatustype , error ) {
2026-01-27 14:29:55 +00:00
phone , err := models . FindCommsPhone ( ctx , db . PGInstance . BobDB , src )
if err != nil {
2026-01-31 20:08:08 +00:00
return enums . CommsPhonestatustypeUnconfirmed , fmt . Errorf ( "Failed to determine if '%s' is subscribed: %w" , src , err )
2026-01-27 14:29:55 +00:00
}
2026-01-31 20:08:08 +00:00
return phone . Status , nil
2026-01-27 14:29:55 +00:00
}
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-02-14 05:05:31 +00:00
err := ensureInDB ( ctx , db . PGInstance . BobDB , destination )
2026-01-26 21:21:21 +00:00
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-31 20:08:08 +00:00
func setPhoneStatus ( ctx context . Context , src string , status enums . CommsPhonestatustype ) error {
2026-01-26 20:51:26 +00:00
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-31 20:08:08 +00:00
Status : omit . From ( status ) ,
2026-01-26 20:51:26 +00:00
} )
2026-01-31 20:08:08 +00:00
log . Info ( ) . Str ( "src" , src ) . Str ( "status" , string ( status ) ) . 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
}