nidus-sync/platform/text/text.go
Eli Ribble 7f71ff9a2e
Send submit PUT on compliance report flow, create communication then
This makes it so that people don't see compliance reports as they're
being formulated in the communication workbench
2026-05-01 21:27:17 +00:00

230 lines
7.4 KiB
Go

package text
import (
"context"
"fmt"
"strings"
"time"
"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 := status != enums.CommsPhonestatustypeUnconfirmed
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, 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)
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, *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, *src)
}
func respondTextLLM(ctx context.Context, 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)
}
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("start txn: %w", err)
}
defer txn.Rollback(ctx)
_, 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)
}
txn.Commit(ctx)
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
}
}