This refactor was born out of the inter-dependency cycles developing between the "background" module and just about every other module which was caused by the background module becoming a dependency of every module that needed to background work and the fact that the background module was also supposedly responsible for the logic for processing those tasks. Instead the "background" module is now very, very shallow and relies entirely on the Postgres NOTIFY logic for triggering jobs. There's a new table, `job` which holds just a type and single row ID. All told, this means that jobs can be added to the queue as part of the API-level or platform-level transaction, ensuring atomicity, and processing coordination is handled by the platform module, which can depend on anything.
405 lines
14 KiB
Go
405 lines
14 KiB
Go
package text
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gleipnir-Technology/bob"
|
|
"github.com/Gleipnir-Technology/bob/dialect/psql"
|
|
"github.com/Gleipnir-Technology/bob/dialect/psql/im"
|
|
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
|
|
"github.com/Gleipnir-Technology/nidus-sync/comms/text"
|
|
"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/db/sql"
|
|
"github.com/Gleipnir-Technology/nidus-sync/llm"
|
|
"github.com/Gleipnir-Technology/nidus-sync/platform/background"
|
|
"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 EnsureInDB(ctx context.Context, ex bob.Executor, destination types.E164) (err error) {
|
|
_, err = psql.Insert(
|
|
im.Into("comms.phone", "e164", "is_subscribed", "status"),
|
|
im.Values(
|
|
psql.Arg(destination.PhoneString()),
|
|
psql.Arg(false),
|
|
psql.Arg("unconfirmed"),
|
|
),
|
|
im.OnConflict("e164").DoNothing(),
|
|
).Exec(ctx, ex)
|
|
return err
|
|
}
|
|
func HandleTextMessage(ctx context.Context, source string, destination string, body 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)
|
|
}
|
|
_, err = insertTextLog(ctx, *dst, *src, enums.CommsTextoriginCustomer, body, false, true)
|
|
if err != nil {
|
|
return fmt.Errorf("insert text log: %w", err)
|
|
}
|
|
status, err := phoneStatus(ctx, *src)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to get phone status")
|
|
}
|
|
body_l := strings.TrimSpace(strings.ToLower(body))
|
|
// We don't know if they're subscribed or not.
|
|
if status == enums.CommsPhonestatustypeUnconfirmed {
|
|
switch body_l {
|
|
case "yes":
|
|
setPhoneStatus(ctx, *src, enums.CommsPhonestatustypeOkToSend)
|
|
content := "Thanks, we've confirmed your phone number. You can text STOP at any time if you change your mind"
|
|
err := sendTextBegin(ctx, *dst, *src, content, enums.CommsTextoriginCommandResponse, false, false)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to send confirmation response")
|
|
}
|
|
handleWaitingTextJobs(ctx, *src)
|
|
default:
|
|
content := "I have to start with either 'YES' or 'STOP' first, Which do you want?"
|
|
err = sendTextBegin(ctx, *dst, *src, content, enums.CommsTextoriginReiteration, false, false)
|
|
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 = sendTextBegin(ctx, *dst, *src, content, enums.CommsTextoriginCommandResponse, false, false)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to send unsubscribe acknowledgement.")
|
|
}
|
|
setPhoneStatus(ctx, *src, enums.CommsPhonestatustypeStopped)
|
|
return nil
|
|
case "reset conversation":
|
|
handleResetConversation(ctx, *src, *dst)
|
|
return nil
|
|
default:
|
|
}
|
|
previous_messages, err := loadPreviousMessagesForLLM(ctx, *dst, *src)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to get previous messages: %w", err)
|
|
}
|
|
log.Info().Int("len", len(previous_messages)).Msg("passing")
|
|
sublog := log.With().Str("dst", destination).Str("src", source).Logger()
|
|
next_message, err := generateNextMessage(ctx, previous_messages, *src)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to generate next message: %w", err)
|
|
}
|
|
err = sendTextBegin(ctx, *dst, *src, next_message.Content, enums.CommsTextoriginLLM, false, true)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to send response text: %w", err)
|
|
}
|
|
sublog.Info().Str("content", next_message.Content).Str("body", body).Str("reply", next_message.Content).Msg("Handled text message")
|
|
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
|
|
}
|
|
}
|
|
func delayMessage(ctx context.Context, source enums.CommsTextjobsource, destination types.E164, content string, type_ enums.CommsTextjobtype) error {
|
|
job, err := models.CommsTextJobs.Insert(&models.CommsTextJobSetter{
|
|
Content: omit.From(content),
|
|
Created: omit.From(time.Now()),
|
|
Destination: omit.From(destination.PhoneString()),
|
|
//ID:
|
|
Source: omit.From(source),
|
|
Type: omit.From(type_),
|
|
}).One(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to add delayed text job: %w", err)
|
|
}
|
|
log.Info().Int32("id", job.ID).Msg("Created delayed text job")
|
|
return nil
|
|
}
|
|
|
|
func resendInitialText(ctx context.Context, src, dst types.E164) error {
|
|
phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, dst.PhoneString())
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to find phone %s: %w", dst, err)
|
|
}
|
|
err = phone.Update(ctx, db.PGInstance.BobDB, &models.CommsPhoneSetter{
|
|
Status: omit.From(enums.CommsPhonestatustypeUnconfirmed),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to clear subscription on phone %s: %w", dst, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sendInitialText(ctx context.Context, src, dst types.E164) error {
|
|
content := "Welcome to Report Mosquitoes Online. We received your request and want to confirm text updates. Reply YES to continue. Reply STOP at any time to unsubscribe"
|
|
origin := enums.CommsTextoriginWebsiteAction
|
|
err := sendTextBegin(ctx, src, dst, content, origin, true, true)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to send initial confirmation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureInitialText(ctx context.Context, src, dst types.E164) error {
|
|
//
|
|
rows, err := models.CommsTextLogs.Query(
|
|
models.SelectWhere.CommsTextLogs.Destination.EQ(dst.PhoneString()),
|
|
models.SelectWhere.CommsTextLogs.IsWelcome.EQ(true),
|
|
).All(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to query text logs: %w", err)
|
|
}
|
|
if len(rows) > 0 {
|
|
return nil
|
|
}
|
|
return sendInitialText(ctx, src, dst)
|
|
}
|
|
|
|
func generateNextMessage(ctx context.Context, history []llm.Message, customer_phone types.E164) (llm.Message, error) {
|
|
_handle_report_status := func() (string, error) {
|
|
return "Report: ABCD-1234-5678, District: Delta MVCD, Status: scheduled, Appointment: Wednesday 3:30pm", nil
|
|
}
|
|
_handle_contact_district := func(reason string) {
|
|
log.Warn().Str("reason", reason).Msg("Contacting district")
|
|
}
|
|
_handle_contact_supervisor := func(reason string) {
|
|
log.Warn().Str("reason", reason).Msg("Contacting supervisor")
|
|
}
|
|
return llm.GenerateNextMessage(ctx, history, _handle_report_status, _handle_contact_district, _handle_contact_supervisor)
|
|
}
|
|
|
|
func handleWaitingTextJobs(ctx context.Context, src types.E164) {
|
|
jobs, err := models.CommsTextJobs.Query(
|
|
models.SelectWhere.CommsTextJobs.Destination.EQ(src.PhoneString()),
|
|
models.SelectWhere.CommsTextJobs.Completed.IsNull(),
|
|
).All(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to query for jobs")
|
|
return
|
|
}
|
|
for _, job := range jobs {
|
|
var source string
|
|
switch job.Source {
|
|
case enums.CommsTextjobsourceRmo:
|
|
source = config.PhoneNumberReportStr
|
|
//case enums.CommsTextJobsourcenidus:
|
|
//src := config.PhoneNumebrNidusStr
|
|
default:
|
|
log.Error().Str("source", job.Source.String()).Msg("Can't support background text job.")
|
|
continue
|
|
}
|
|
p, err := phonenumbers.Parse(job.Destination, "US")
|
|
if err != nil {
|
|
log.Error().Err(err).Str("dest", job.Destination).Int32("id", job.ID).Msg("Invalid destination in job")
|
|
continue
|
|
}
|
|
dst := types.NewE164(p)
|
|
p, err = phonenumbers.Parse(source, "US")
|
|
if err != nil {
|
|
log.Error().Err(err).Str("source", source).Int32("id", job.ID).Msg("Invalid source in job")
|
|
continue
|
|
}
|
|
src := types.NewE164(p)
|
|
err = sendTextBegin(ctx, *src, *dst, job.Content, enums.CommsTextoriginWebsiteAction, false, true)
|
|
if err != nil {
|
|
log.Error().Err(err).Int32("id", job.ID).Msg("Failed to send delayed text job.")
|
|
continue
|
|
}
|
|
err = job.Update(ctx, db.PGInstance.BobDB, &models.CommsTextJobSetter{
|
|
Completed: omitnull.From(time.Now()),
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Int32("id", job.ID).Msg("Failed to update delayed text job.")
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleResetConversation(ctx context.Context, src types.E164, dst types.E164) {
|
|
err := wipeLLMMemory(ctx, src, dst)
|
|
sublog := log.With().Str("src", src.PhoneString()).Str("dst", dst.PhoneString()).Logger()
|
|
if err != nil {
|
|
sublog.Error().Err(err).Msg("Failed to wipe memory")
|
|
content := "Failed to wip memory"
|
|
err = sendTextBegin(ctx, dst, src, content, enums.CommsTextoriginCommandResponse, false, false)
|
|
if err != nil {
|
|
sublog.Error().Err(err).Msg("Failed to indicated memory wipe failure.")
|
|
}
|
|
return
|
|
}
|
|
content := "LLM memory wiped"
|
|
err = sendTextBegin(ctx, dst, src, content, enums.CommsTextoriginCommandResponse, false, false)
|
|
if err != nil {
|
|
sublog.Error().Err(err).Msg("Failed to indicated memory wiped.")
|
|
return
|
|
}
|
|
sublog.Info().Err(err).Msg("Wiped LLM memory")
|
|
}
|
|
|
|
func insertTextLog(ctx context.Context, destination types.E164, source types.E164, origin enums.CommsTextorigin, content string, is_welcome bool, is_visible_to_llm bool) (l *models.CommsTextLog, err error) {
|
|
l, err = models.CommsTextLogs.Insert(&models.CommsTextLogSetter{
|
|
//ID:
|
|
Content: omit.From(content),
|
|
Created: omit.From(time.Now()),
|
|
Destination: omit.From(destination.PhoneString()),
|
|
IsVisibleToLLM: omit.From(is_visible_to_llm),
|
|
IsWelcome: omit.From(is_welcome),
|
|
Origin: omit.From(origin),
|
|
Source: omit.From(source.PhoneString()),
|
|
TwilioSid: omitnull.FromPtr[string](nil),
|
|
TwilioStatus: omit.From(""),
|
|
}).One(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
log.Debug().Int32("id", l.ID).Bool("is_visible_to_llm", is_visible_to_llm).Str("message", content).Msg("inserted text log")
|
|
}
|
|
|
|
return l, err
|
|
}
|
|
|
|
func phoneStatus(ctx context.Context, src types.E164) (enums.CommsPhonestatustype, error) {
|
|
phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, src.PhoneString())
|
|
if err != nil {
|
|
return enums.CommsPhonestatustypeUnconfirmed, fmt.Errorf("Failed to determine if '%s' is subscribed: %w", src.PhoneString(), err)
|
|
}
|
|
return phone.Status, nil
|
|
}
|
|
|
|
func loadPreviousMessagesForLLM(ctx context.Context, dst, src types.E164) ([]llm.Message, error) {
|
|
messages, err := sql.TextsBySenders(dst.PhoneString(), src.PhoneString()).All(ctx, db.PGInstance.BobDB)
|
|
results := make([]llm.Message, 0)
|
|
if err != nil {
|
|
return results, fmt.Errorf("Failed to get message history for %s and %s: %w", dst, src, err)
|
|
}
|
|
for _, m := range messages {
|
|
if m.IsVisibleToLLM {
|
|
is_from_customer := (m.Source == src.PhoneString())
|
|
results = append(results, llm.Message{
|
|
IsFromCustomer: is_from_customer,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func sendTextBegin(ctx context.Context, source types.E164, destination types.E164, message string, origin enums.CommsTextorigin, is_welcome bool, is_visible_to_llm bool) error {
|
|
err := EnsureInDB(ctx, db.PGInstance.BobDB, destination)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to ensure text message destination is in the DB: %w", err)
|
|
}
|
|
l, err := insertTextLog(ctx, destination, source, origin, message, is_welcome, is_visible_to_llm)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to insert text message in the DB: %w", err)
|
|
}
|
|
return background.NewTextSend(ctx, db.PGInstance.BobDB, l.ID)
|
|
}
|
|
func sendTextComplete(ctx context.Context, txn bob.Executor, text_id int32) error {
|
|
text_log, err := models.FindCommsTextLog(ctx, txn, text_id)
|
|
if err != nil {
|
|
return fmt.Errorf("find text: %w", err)
|
|
}
|
|
sid, err := text.SendText(ctx, text_log.Source, text_log.Destination, text_log.Content)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to send text message: %w", err)
|
|
}
|
|
err = text_log.Update(ctx, db.PGInstance.BobDB, &models.CommsTextLogSetter{
|
|
TwilioSid: omitnull.From(sid),
|
|
TwilioStatus: omit.From("created"),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to update text Twilio status: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func setPhoneStatus(ctx context.Context, src types.E164, status enums.CommsPhonestatustype) error {
|
|
phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, src.PhoneString())
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to determine if '%s' is subscribed: %w", src, err)
|
|
}
|
|
phone.Update(ctx, db.PGInstance.BobDB, &models.CommsPhoneSetter{
|
|
Status: omit.From(status),
|
|
})
|
|
log.Info().Str("src", src.PhoneString()).Str("status", string(status)).Msg("Set number subscribed")
|
|
return nil
|
|
}
|
|
|
|
func wipeLLMMemory(ctx context.Context, src types.E164, dst types.E164) error {
|
|
rows, err := sql.TextsBySenders(dst.PhoneString(), src.PhoneString()).All(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to query for texts: %w", err)
|
|
}
|
|
ids := make([]int32, 0)
|
|
for _, r := range rows {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
_, err = models.CommsTextLogs.Update(
|
|
um.Where(
|
|
models.CommsTextLogs.Columns.ID.EQ(psql.Any(ids)),
|
|
),
|
|
um.SetCol("is_visible_to_llm").ToArg(false),
|
|
).Exec(ctx, db.PGInstance.BobDB)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to update texts: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|