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.
260 lines
8.6 KiB
Go
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
|
|
}
|
|
}
|