nidus-sync/platform/text/text.go
Eli Ribble 393836a86a
Fix notification of job happening before transaction is closed
This is kind of a wild one. Turns out that the triggers I was using
actually fire before the transaction is closed and I was primarily
getting lucky that the job was present on the other side of the
connection rather than having things built correctly.

I've fixed this by removing the trigger entirely and instead manually
triggering as part of the transaction. This makes the NOTIFY call happen
as soon as the transaction closes, just at the cost of making my
application be in charge of ensuring the NOTIFY gets called. Seems like
a win.

Part of doing this is porting the existing job creation code over to use
Jet. It's something I want to do anyway, so it's a win all around.
2026-05-22 23:34:38 +00:00

260 lines
8.6 KiB
Go

package text
import (
"context"
"fmt"
"strings"
"time"
"github.com/aarondl/opt/omit"
"github.com/nyaruka/phonenumbers"
"github.com/rs/zerolog/log"
"source.gleipnir.technology/Gleipnir/nidus-sync/config"
"source.gleipnir.technology/Gleipnir/nidus-sync/db"
modelcomms "source.gleipnir.technology/Gleipnir/nidus-sync/db/gen/nidus-sync/comms/model"
modelpublicreport "source.gleipnir.technology/Gleipnir/nidus-sync/db/gen/nidus-sync/publicreport/model"
"source.gleipnir.technology/Gleipnir/nidus-sync/db/models"
querycomms "source.gleipnir.technology/Gleipnir/nidus-sync/db/query/comms"
querypublicreport "source.gleipnir.technology/Gleipnir/nidus-sync/db/query/publicreport"
"source.gleipnir.technology/Gleipnir/nidus-sync/lint"
"source.gleipnir.technology/Gleipnir/nidus-sync/platform/background"
"source.gleipnir.technology/Gleipnir/nidus-sync/platform/event"
"source.gleipnir.technology/Gleipnir/nidus-sync/platform/types"
)
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.BeginTxn(ctx)
if err != nil {
return fmt.Errorf("start txn: %w", err)
}
defer lint.LogOnErrRollback(txn.Rollback, ctx, "rollback")
s, err := ensurePhoneInDB(ctx, txn, src)
if err != nil {
return fmt.Errorf("ensure source in DB: %w", err)
}
is_visible_to_llm := s.ConfirmedMessageID != nil
l, err := querycomms.TextLogInsert(ctx, txn, modelcomms.TextLog{
Content: content,
Created: time.Now(),
Destination: dst.PhoneString(),
IsVisibleToLlm: is_visible_to_llm,
IsWelcome: false,
Origin: modelcomms.Textorigin_Customer,
Source: s.E164,
TwilioSid: nil,
TwilioStatus: "",
})
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)
}
if err := txn.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
log.Debug().Msg("commit handle text message")
return err
}
func respondText(ctx context.Context, log_id int32) error {
txn, err := db.BeginTxn(ctx)
log.Debug().Msg("respond text txn begin")
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer lint.LogOnErrRollback(txn.Rollback, ctx, "rollback")
l, err := querycomms.TextLogFromID(ctx, txn, int64(log_id))
if err != nil {
return fmt.Errorf("find comms %d: %w", log_id, err)
}
src, err := ParsePhoneNumber(l.Source)
if err != nil {
return fmt.Errorf("parse source: %w", err)
}
phone, err := querycomms.PhoneFromE164(ctx, txn, src.PhoneString())
if err != nil {
return fmt.Errorf("Failed to get contact phone")
}
body_l := strings.TrimSpace(strings.ToLower(l.Content))
// If the user isn't confirmed for sending regular texts ensure they get a reprompt
if phone.ConfirmedMessageID == nil {
switch body_l {
case "yes":
err = querycomms.PhoneUpdateConfirmedMessageID(ctx, txn, src.PhoneString(), &l.ID)
if err != nil {
return fmt.Errorf("set phone confirmed message ID: %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)
}
lint.LogOnErrCtx(func(ctx context.Context) error {
return handleWaitingTextJobs(ctx, *src)
}, ctx, "handle waiting text jobs")
// 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":
err = querycomms.PhoneUpdateStopMessageID(ctx, txn, src.PhoneString(), &l.ID)
if err != nil {
return fmt.Errorf("set phone stop message ID: %w", err)
}
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.")
}
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"
lint.LogOnErrCtx(func(ctx context.Context) error {
return sendTextCommandResponse(ctx, txn, *src, content)
}, ctx, "send text command response")
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
// Get the list of reports that are still open for a particular text message recipient
// 'still open' is not well-defined throughout the system, but for now we'll go with
// 'not reviewed in any way'.
reports, err := querypublicreport.ReportsFromReporterPhone(ctx, txn, src.PhoneString())
if err != nil {
return fmt.Errorf("has open report: %w", err)
}
for _, report := range reports {
_, err = querypublicreport.ReportLogInsert(ctx, txn, modelpublicreport.ReportLog{
Created: time.Now(),
EmailLogID: nil,
// ID
ReportID: report.ID,
TextLogID: &log_id,
Type: modelpublicreport.Reportlogtype_MessageText,
UserID: nil,
})
if err != nil {
return fmt.Errorf("insert report log: %w", err)
}
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
if config.DoLLMResponse {
return respondTextLLM(ctx, *src)
}
return nil
}
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.BeginTxn(ctx)
if err != nil {
return fmt.Errorf("start txn: %w", err)
}
defer lint.LogOnErrRollback(txn.Rollback, ctx, "rollback")
_, err = sendTextDirect(ctx, txn, modelcomms.Textorigin_Llm, src.PhoneString(), next_message.Content, true, false)
if err != nil {
return fmt.Errorf("Failed to send response text: %w", err)
}
if err := txn.Commit(ctx); err != nil {
return fmt.Errorf("commit: %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 {
var err error
ctx := context.TODO()
txn := db.PGInstance.PGXPool
// Magical id 1 is set in migration 00151
contact, err := querycomms.ContactFromID(ctx, txn, 1)
if err != nil {
return fmt.Errorf("contact from ID 1: %w", err)
}
for _, n := range []string{config.PhoneNumberReportStr, config.PhoneNumberSupportStr, config.VoipMSNumber} {
// 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, txn, contact, *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, txn, contact, *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
}
}