This in a pretty huge change. At a high level we're adding the concept of a 'contact' which is a person or organization that has zero or more contact methods (email, phone). This ended up cascading a number of changes, including critically to the publicreprt schema. In the end it seemed safer to get to the point where I'm confident we aren't using any of the old fields for storing reporter information (though I haven't deleted the columns yet) so I removed the code for defining those columns. At this point I think it's not possible for me to regenerate the bob schema due to the interdependencies between my various schemas, so the migration is well-and-truly happening.
259 lines
8.8 KiB
Go
259 lines
8.8 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"
|
|
modelcomms "github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/comms/model"
|
|
modelpublicreport "github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/publicreport/model"
|
|
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
|
querycomms "github.com/Gleipnir-Technology/nidus-sync/db/query/comms"
|
|
querypublicreport "github.com/Gleipnir-Technology/nidus-sync/db/query/publicreport"
|
|
"github.com/Gleipnir-Technology/nidus-sync/lint"
|
|
"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 lint.LogOnErrRollback(txn.Rollback, ctx, "rollback")
|
|
|
|
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)
|
|
}
|
|
if err := txn.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func respondText(ctx context.Context, log_id int32) error {
|
|
txn, err := db.BeginTxn(ctx)
|
|
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: %w", err)
|
|
}
|
|
src, err := ParsePhoneNumber(l.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("parse source: %w", err)
|
|
}
|
|
|
|
contact_phone, err := querycomms.ContactPhoneFromE164(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 contact_phone.ConfirmedMessageID == nil {
|
|
switch body_l {
|
|
case "yes":
|
|
err = querycomms.ContactPhoneUpdateConfirmedMessageID(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.ContactPhoneUpdateStopMessageID(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
|
|
//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
|
|
}
|
|
}
|