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
|
|
|
|
|
|
|
|
"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"
|
2026-03-16 19:52:29 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/platform/background"
|
2026-03-18 15:36:20 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/platform/event"
|
2026-03-16 19:52:29 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
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-03-18 15:36:20 +00:00
|
|
|
func HandleTextMessage(ctx context.Context, source string, destination string, content string) error {
|
2026-03-16 19:52:29 +00:00
|
|
|
src, err := ParsePhoneNumber(source)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("parse source '%s': %w", source, err)
|
2026-03-15 22:38:36 +00:00
|
|
|
}
|
2026-03-16 19:52:29 +00:00
|
|
|
dst, err := ParsePhoneNumber(destination)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:52:29 +00:00
|
|
|
return fmt.Errorf("parse destination '%s': %w", destination, err)
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
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")
|
|
|
|
|
}
|
2026-05-01 21:27:17 +00:00
|
|
|
is_visible_to_llm := status != enums.CommsPhonestatustypeUnconfirmed
|
2026-05-01 20:49:37 +00:00
|
|
|
|
2026-03-18 15:36:20 +00:00
|
|
|
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)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:52:29 +00:00
|
|
|
return fmt.Errorf("insert text log: %w", err)
|
|
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:53:23 +00:00
|
|
|
func respondText(ctx context.Context, log_id int32) error {
|
|
|
|
|
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer txn.Rollback(ctx)
|
2026-03-18 15:36:20 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 19:52:29 +00:00
|
|
|
status, err := phoneStatus(ctx, *src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("Failed to get phone status")
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
|
|
|
|
|
body_l := strings.TrimSpace(strings.ToLower(l.Content))
|
|
|
|
|
// If the user isn't confirmed for sending regular texts ensure they get a reprompt
|
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-03-18 15:36:20 +00:00
|
|
|
err = setPhoneStatus(ctx, txn, *src, enums.CommsPhonestatustypeOkToSend)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("set phone status: %w", err)
|
|
|
|
|
}
|
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"
|
2026-03-18 15:36:20 +00:00
|
|
|
err = sendTextCommandResponse(ctx, txn, *src, content)
|
2026-01-31 20:16:12 +00:00
|
|
|
if err != nil {
|
2026-03-18 15:36:20 +00:00
|
|
|
return fmt.Errorf("send response: %w", err)
|
2026-01-31 20:16:12 +00:00
|
|
|
}
|
2026-04-17 22:53:23 +00:00
|
|
|
handleWaitingTextJobs(ctx, *src)
|
2026-03-18 15:36:20 +00:00
|
|
|
// We don't handle 'stop' here because we allow them to say 'stop' at any time, regardless of
|
|
|
|
|
// phone status.
|
|
|
|
|
//case "stop":
|
2026-01-27 14:29:55 +00:00
|
|
|
default:
|
|
|
|
|
content := "I have to start with either 'YES' or 'STOP' first, Which do you want?"
|
2026-03-18 15:36:20 +00:00
|
|
|
err = sendTextCommandResponse(ctx, txn, *src, content)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("Failed to resend initial prompt.")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-16 19:52:29 +00:00
|
|
|
return nil
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
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."
|
2026-03-18 15:36:20 +00:00
|
|
|
err = sendTextCommandResponse(ctx, txn, *src, content)
|
2026-01-29 21:53:49 +00:00
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("Failed to send unsubscribe acknowledgement.")
|
|
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
setPhoneStatus(ctx, txn, *src, enums.CommsPhonestatustypeStopped)
|
2026-03-16 19:52:29 +00:00
|
|
|
return nil
|
2026-01-29 21:53:49 +00:00
|
|
|
case "reset conversation":
|
2026-03-18 15:36:20 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-16 19:52:29 +00:00
|
|
|
return nil
|
2026-01-27 18:44:02 +00:00
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
// If we've got an open public report from this phone number then we'll let the district respond
|
2026-03-18 18:56:51 +00:00
|
|
|
reports, err := reportsForTextRecipient(ctx, txn, *src)
|
2026-03-18 15:36:20 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("has open report: %w", err)
|
|
|
|
|
}
|
2026-03-18 18:56:51 +00:00
|
|
|
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)
|
2026-04-13 16:43:15 +00:00
|
|
|
event.Updated(event.TypeRMOPublicReport, report.OrganizationID, report.PublicID)
|
2026-03-18 15:36:20 +00:00
|
|
|
}
|
2026-03-18 18:56:51 +00:00
|
|
|
// If humans are involved, wait for them.
|
|
|
|
|
if len(reports) > 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-03-18 15:36:20 +00:00
|
|
|
// Otherwise let the LLM handle the response
|
2026-04-17 22:53:23 +00:00
|
|
|
return respondTextLLM(ctx, *src)
|
2026-03-18 15:36:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:53:23 +00:00
|
|
|
func respondTextLLM(ctx context.Context, src types.E164) error {
|
2026-03-18 15:36:20 +00:00
|
|
|
previous_messages, err := loadPreviousMessagesForLLM(ctx, src)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:52:29 +00:00
|
|
|
return fmt.Errorf("Failed to get previous messages: %w", err)
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
|
|
|
|
log.Info().Int("len", len(previous_messages)).Msg("passing")
|
2026-03-18 15:36:20 +00:00
|
|
|
next_message, err := generateNextMessage(ctx, previous_messages, src)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:52:29 +00:00
|
|
|
return fmt.Errorf("Failed to generate next message: %w", err)
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
2026-04-17 22:53:23 +00:00
|
|
|
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("start txn: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer txn.Rollback(ctx)
|
2026-03-18 15:36:20 +00:00
|
|
|
_, err = sendTextDirect(ctx, txn, enums.CommsTextoriginLLM, src.PhoneString(), next_message.Content, true, false)
|
2026-01-27 14:29:55 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:52:29 +00:00
|
|
|
return fmt.Errorf("Failed to send response text: %w", err)
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
2026-04-17 22:53:23 +00:00
|
|
|
txn.Commit(ctx)
|
2026-03-16 19:52:29 +00:00
|
|
|
return nil
|
2026-01-27 14:29:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 19:52:29 +00:00
|
|
|
func ParsePhoneNumber(input string) (*types.E164, error) {
|
2026-03-15 22:38:36 +00:00
|
|
|
n, err := phonenumbers.Parse(input, "US")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-03-16 19:52:29 +00:00
|
|
|
return types.NewE164(n), nil
|
2026-02-10 04:07:59 +00:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|