package text import ( "context" "fmt" "strings" "time" "github.com/Gleipnir-Technology/bob" "github.com/Gleipnir-Technology/nidus-sync/config" "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/platform/background" "github.com/Gleipnir-Technology/nidus-sync/platform/event" "github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/aarondl/opt/omit" "github.com/aarondl/opt/omitnull" "github.com/nyaruka/phonenumbers" "github.com/rs/zerolog/log" ) func HandleTextMessage(ctx context.Context, source string, destination string, content string) error { src, err := ParsePhoneNumber(source) if err != nil { return fmt.Errorf("parse source '%s': %w", source, err) } dst, err := ParsePhoneNumber(destination) if err != nil { return fmt.Errorf("parse destination '%s': %w", destination, err) } txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("start txn: %w", err) } defer txn.Rollback(ctx) status, err := phoneStatus(ctx, *src) if err != nil { return fmt.Errorf("Failed to get phone status") } is_visible_to_llm := true if status == enums.CommsPhonestatustypeUnconfirmed { is_visible_to_llm = false } l, err := models.CommsTextLogs.Insert(&models.CommsTextLogSetter{ //ID: Content: omit.From(content), Created: omit.From(time.Now()), Destination: omit.From(dst.PhoneString()), IsVisibleToLLM: omit.From(is_visible_to_llm), IsWelcome: omit.From(false), Origin: omit.From(enums.CommsTextoriginCustomer), Source: omit.From(src.PhoneString()), TwilioSid: omitnull.FromPtr[string](nil), TwilioStatus: omit.From(""), }).One(ctx, txn) if err != nil { return fmt.Errorf("insert text log: %w", err) } log.Debug().Int32("id", l.ID).Msg("insert comms text log") err = background.NewTextRespond(ctx, txn, l.ID) if err != nil { return fmt.Errorf("text respond: %w", err) } txn.Commit(ctx) return err } func respondText(ctx context.Context, txn bob.Executor, log_id int32) error { l, err := models.FindCommsTextLog(ctx, txn, log_id) if err != nil { return fmt.Errorf("find comms: %w", err) } src, err := ParsePhoneNumber(l.Source) if err != nil { return fmt.Errorf("parse source: %w", err) } status, err := phoneStatus(ctx, *src) if err != nil { return fmt.Errorf("Failed to get phone status") } body_l := strings.TrimSpace(strings.ToLower(l.Content)) // If the user isn't confirmed for sending regular texts ensure they get a reprompt if status == enums.CommsPhonestatustypeUnconfirmed { switch body_l { case "yes": err = setPhoneStatus(ctx, txn, *src, enums.CommsPhonestatustypeOkToSend) if err != nil { return fmt.Errorf("set phone status: %w", err) } content := "Thanks, we've confirmed your phone number. You can text STOP at any time if you change your mind" err = sendTextCommandResponse(ctx, txn, *src, content) if err != nil { return fmt.Errorf("send response: %w", err) } handleWaitingTextJobs(ctx, txn, *src) // We don't handle 'stop' here because we allow them to say 'stop' at any time, regardless of // phone status. //case "stop": default: content := "I have to start with either 'YES' or 'STOP' first, Which do you want?" err = sendTextCommandResponse(ctx, txn, *src, content) if err != nil { log.Error().Err(err).Msg("Failed to resend initial prompt.") } } return nil } 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 = sendTextCommandResponse(ctx, txn, *src, content) if err != nil { log.Error().Err(err).Msg("Failed to send unsubscribe acknowledgement.") } setPhoneStatus(ctx, txn, *src, enums.CommsPhonestatustypeStopped) return nil case "reset conversation": err = handleResetConversation(ctx, txn, *src) if err != nil { log.Error().Err(err).Msg("Failed to wipe memory") content := "Failed to wipe memory" sendTextCommandResponse(ctx, txn, *src, content) return fmt.Errorf("reset conversation: %w", err) } return nil } // If we've got an open public report from this phone number then we'll let the district respond reports, err := reportsForTextRecipient(ctx, txn, *src) if err != nil { return fmt.Errorf("has open report: %w", err) } for _, report := range reports { models.PublicreportReportLogs.Insert(&models.PublicreportReportLogSetter{ Created: omit.From(time.Now()), EmailLogID: omitnull.FromPtr[int32](nil), // ID ReportID: omit.From(report.ID), TextLogID: omitnull.From(log_id), Type: omit.From(enums.PublicreportReportlogtypeMessageText), UserID: omitnull.FromPtr[int32](nil), }).One(ctx, txn) event.Updated(event.TypeRMOPublicReport, report.OrganizationID, report.PublicID) } // If humans are involved, wait for them. if len(reports) > 0 { return nil } // Otherwise let the LLM handle the response return respondTextLLM(ctx, txn, *src) } func respondTextLLM(ctx context.Context, txn bob.Executor, src types.E164) error { previous_messages, err := loadPreviousMessagesForLLM(ctx, src) if err != nil { return fmt.Errorf("Failed to get previous messages: %w", err) } log.Info().Int("len", len(previous_messages)).Msg("passing") next_message, err := generateNextMessage(ctx, previous_messages, src) if err != nil { return fmt.Errorf("Failed to generate next message: %w", err) } _, err = sendTextDirect(ctx, txn, enums.CommsTextoriginLLM, src.PhoneString(), next_message.Content, true, false) if err != nil { return fmt.Errorf("Failed to send response text: %w", err) } return nil } func ParsePhoneNumber(input string) (*types.E164, error) { n, err := phonenumbers.Parse(input, "US") if err != nil { return nil, err } return types.NewE164(n), nil } func StoreSources() error { ctx := context.TODO() for _, n := range []string{config.PhoneNumberReportStr, config.PhoneNumberSupportStr, config.VoipMSNumber} { var err error // Deal with Voip.ms not expecting API calls with the prefixed +1 if !strings.HasPrefix(n, "+1") { dest, err := ParsePhoneNumber("+1" + n) if err != nil { return fmt.Errorf("Failed to parse +1'%s' as phone number: %w", n, err) } err = EnsureInDB(ctx, db.PGInstance.BobDB, *dest) } else { dest, err := ParsePhoneNumber(n) if err != nil { return fmt.Errorf("Failed to parse '%s' as phone number: %w", n, err) } err = EnsureInDB(ctx, db.PGInstance.BobDB, *dest) } if err != nil { return fmt.Errorf("Failed to add number '%s' to DB: %w", n, err) } } return nil } 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 } }