]> git.giacomolli.uk - onechat/blobdiff - whatsapp_inbox.go
Order model and chat declarations
[onechat] / whatsapp_inbox.go
index 5121976c6d00307e8e227a57cdca558a11a64a9b..a104a0d543a85f97df273841fba5b052c27a94cf 100644 (file)
@@ -2,9 +2,9 @@ package main
 
 import (
        "context"
 
 import (
        "context"
-       "crypto/sha256"
        "fmt"
        "log/slog"
        "fmt"
        "log/slog"
+       "path/filepath"
        "slices"
        "strings"
        "sync"
        "slices"
        "strings"
        "sync"
@@ -16,48 +16,38 @@ import (
 )
 
 const (
 )
 
 const (
+       // whatsappContactLookupTimeout is the longest time of a read of names
+       // in the session.
+       whatsappContactLookupTimeout = 10 * time.Second
+
+       // whatsappImageDownloadTimeout is the longest time of an image
+       // download.
+       whatsappImageDownloadTimeout = 2 * time.Minute
+
        // whatsappSource is the source of the work events of the batches.
        whatsappSource = "whatsapp"
 
        // whatsappSource is the source of the work events of the batches.
        whatsappSource = "whatsapp"
 
-       // whatsappCatchupLimit is the longest wait for the end of the catch-up
-       // after a connection.
-       whatsappCatchupLimit = time.Minute
+       // whatsappCatchupTimeout is the longest wait for the end of the
+       // catch-up after a connection.
+       whatsappCatchupTimeout = time.Minute
 )
 
 )
 
-// newWhatsAppInbox returns the inbox of the batches in state, with clock for
-// the times of the batch texts and groups for the names of the groups. images
-// holds shared images, and logger gets image failures.
-func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups, images *ImageStore, logger *slog.Logger) *WhatsAppInbox {
-       return &WhatsAppInbox{
-               state:        state,
-               clock:        clock,
-               groups:       groups,
-               images:       images,
-               logger:       logger,
-               lookupTime:   whatsappLookupTime,
-               imageTime:    whatsappImageTime,
-               catchupLimit: whatsappCatchupLimit,
-               wake:         make(chan struct{}, 1),
-               failures:     make(chan error, 1),
-       }
-}
-
 // WhatsAppInbox puts messages into batches, one chat at a time, as
 // WhatsAppState says. deliver logs each closed batch as a work message that
 // starts with [WhatsApp], and a batch that needs the user wakes the turn loop.
 //
 // After each connection, the servers send the messages that came while the
 // daemon was away, and then OfflineSyncCompleted. Until then, or until
 // WhatsAppInbox puts messages into batches, one chat at a time, as
 // WhatsAppState says. deliver logs each closed batch as a work message that
 // starts with [WhatsApp], and a batch that needs the user wakes the turn loop.
 //
 // After each connection, the servers send the messages that came while the
 // daemon was away, and then OfflineSyncCompleted. Until then, or until
-// catchupLimit passes, the closed batches wait in the state file. At the end of
-// the catch-up, deliver closes all batches and logs them. It wakes the root
-// once for its batches, so their catch-up gives one turn, not one for each
-// chat. The batches of the state file at the start wait for the first catch-up
-// in the same way. The event handler acknowledges a message only after the
-// state file holds it, so the servers send again a message that a crash lost. A
-// batch leaves the state file only after its work message is durable in the log
-// or agent queue. A crash between the two can repeat an unrouted batch once. A
-// routed batch enters the durable agent queue, which prevents a second log
-// entry and wakes only the child. A missing child uses the root.
+// whatsappCatchupTimeout passes, the closed batches wait in the state file. At
+// the end of the catch-up, deliver closes all batches and logs them. It wakes
+// the root once for its batches, so their catch-up gives one turn, not one for
+// each chat. The batches of the state file at the start wait for the first
+// catch-up in the same way. The event handler acknowledges a message only after
+// the state file holds it, so the servers send again a message that a crash
+// lost. A batch leaves the state file only after its work message is durable in
+// the log or agent queue. A crash between the two can repeat an unrouted batch
+// once. A routed batch enters the durable agent queue, which prevents a second
+// log entry and wakes only the child. A missing child uses the root.
 //
 // The state file is the one of WhatsApp, which keeps the send records in it
 // too. groups gives the names of the groups. The mutex protects the state of
 //
 // The state file is the one of WhatsApp, which keeps the send records in it
 // too. groups gives the names of the groups. The mutex protects the state of
@@ -65,16 +55,15 @@ func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *What
 // goroutines. wake tells Run that a batch can be due or that a catch-up ended,
 // and failures gives Run the error of a write of the state file.
 type WhatsAppInbox struct {
 // goroutines. wake tells Run that a batch can be due or that a catch-up ended,
 // and failures gives Run the error of a write of the state file.
 type WhatsAppInbox struct {
-       state        *WhatsAppState
-       clock        func() time.Time
-       groups       *WhatsAppGroups
-       images       *ImageStore
-       logger       *slog.Logger
-       lookupTime   time.Duration
-       imageTime    time.Duration
-       catchupLimit time.Duration
-       wake         chan struct{}
-       failures     chan error
+       state    *WhatsAppState
+       clock    func() time.Time
+       groups   *WhatsAppGroups
+       images   *ImageStore
+       voices   *VoiceStore
+       voice    *VoiceTranscriber
+       logger   *slog.Logger
+       wake     chan struct{}
+       failures chan error
 
        mutex        sync.Mutex
        catchingUp   bool
 
        mutex        sync.Mutex
        catchingUp   bool
@@ -83,6 +72,24 @@ type WhatsAppInbox struct {
        catchupTimer *time.Timer
 }
 
        catchupTimer *time.Timer
 }
 
+// newWhatsAppInbox returns the inbox of the batches in state, with clock for
+// the times of the batch texts and groups for the names of the groups. images
+// holds shared images, and logger gets image failures. Audio shares the data
+// directory of state.
+func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups, images *ImageStore, logger *slog.Logger) *WhatsAppInbox {
+       return &WhatsAppInbox{
+               state:    state,
+               clock:    clock,
+               groups:   groups,
+               images:   images,
+               voices:   &VoiceStore{dir: filepath.Join(filepath.Dir(filepath.Dir(state.path)), voiceDirName)},
+               voice:    &VoiceTranscriber{},
+               logger:   logger,
+               wake:     make(chan struct{}, 1),
+               failures: make(chan error, 1),
+       }
+}
+
 // due returns a channel that gets a value when the next open batch is due, or
 // nil when no batch is open.
 func (i *WhatsAppInbox) due() <-chan time.Time {
 // due returns a channel that gets a value when the next open batch is due, or
 // nil when no batch is open.
 func (i *WhatsAppInbox) due() <-chan time.Time {
@@ -206,7 +213,7 @@ func (i *WhatsAppInbox) waitForCatchup() {
 }
 
 // startCatchup starts the catch-up of a new connection, which ends at
 }
 
 // startCatchup starts the catch-up of a new connection, which ends at
-// endCatchup or after catchupLimit.
+// endCatchup or after whatsappCatchupTimeout.
 func (i *WhatsAppInbox) startCatchup() {
        i.mutex.Lock()
        defer i.mutex.Unlock()
 func (i *WhatsAppInbox) startCatchup() {
        i.mutex.Lock()
        defer i.mutex.Unlock()
@@ -218,30 +225,32 @@ func (i *WhatsAppInbox) startCatchup() {
                i.catchupTimer.Stop()
        }
 
                i.catchupTimer.Stop()
        }
 
-       i.catchupTimer = time.AfterFunc(i.catchupLimit, func() {
+       i.catchupTimer = time.AfterFunc(whatsappCatchupTimeout, func() {
                i.caughtUpBy(catchups)
        })
 }
 
                i.caughtUpBy(catchups)
        })
 }
 
-// receive stores an image before its batch and wakes Run. link gives the device
-// and the own IDs. It returns false when the state file cannot hold the
-// message, so that the library does not acknowledge it, and Run stops the
-// daemon. An image that cannot be downloaded or stored gets a text marker. A
+// receive stores images and transcribed voice notes before their batches and
+// wakes Run. link gives the device and the own IDs. It returns false when the
+// state file cannot hold the message, so that the library does not acknowledge
+// it, and Run stops the daemon. Failed media input gets a text marker. A
 // message that the batches do not take is handled with nothing to do.
 // message that the batches do not take is handled with nothing to do.
-func (i *WhatsAppInbox) receive(link WhatsAppLink, event *events.Message) bool {
-       ctx, cancel := context.WithTimeout(context.Background(), i.lookupTime)
+func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *events.Message) bool {
+       lookupCtx, cancel := context.WithTimeout(ctx, whatsappContactLookupTimeout)
        defer cancel()
 
        defer cancel()
 
-       chat, message, found := whatsappMessage(ctx, link.device, link.own, event)
+       chat, message, found := whatsappMessage(lookupCtx, link.device, link.own, event)
        if !found {
                return true
        }
 
        if event.Message.GetImageMessage() != nil {
        if !found {
                return true
        }
 
        if event.Message.GetImageMessage() != nil {
-               ctx, cancel := context.WithTimeout(context.Background(), i.imageTime)
+               ctx, cancel := context.WithTimeout(ctx, whatsappImageDownloadTimeout)
                defer cancel()
 
                message.Text = i.imageText(ctx, link, event, message.Text)
                defer cancel()
 
                message.Text = i.imageText(ctx, link, event, message.Text)
+       } else if event.Message.GetAudioMessage().GetPTT() {
+               message.Text = whatsappRedact(link.own, whatsappVoice(ctx, link.client, i.voices, i.voice, event.Message.GetAudioMessage()))
        }
 
        _, err := i.state.Add(chat, message, time.Now())
        }
 
        _, err := i.state.Add(chat, message, time.Now())
@@ -269,9 +278,8 @@ func (i *WhatsAppInbox) imageText(ctx context.Context, link WhatsAppLink, event
        name, err := whatsappImage(ctx, link.client, i.images, event.Message.GetImageMessage())
        if err != nil {
                chat := event.Info.Chat.ToNonAD()
        name, err := whatsappImage(ctx, link.client, i.images, event.Message.GetImageMessage())
        if err != nil {
                chat := event.Info.Chat.ToNonAD()
-               digest := sha256.Sum256([]byte(chat.String()))
                redact := append(slices.Clone(link.own), chat.User)
                redact := append(slices.Clone(link.own), chat.User)
-               i.logger.Error("image receive failed", "chat", fmt.Sprintf("%x", digest[:6]), "err", whatsappRedact(redact, err.Error()))
+               i.logger.Error("image receive failed", "chat", whatsappChatHash(chat), "err", whatsappRedact(redact, err.Error()))
 
                text := "(image not downloaded)"
                if caption != "" {
 
                text := "(image not downloaded)"
                if caption != "" {
@@ -356,7 +364,7 @@ func whatsappMessage(ctx context.Context, device *store.Device, own []string, ev
        chatJID := info.Chat.ToNonAD()
        switch {
        case device == nil:
        chatJID := info.Chat.ToNonAD()
        switch {
        case device == nil:
-       case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.Message.GetImageMessage().GetViewOnce(), event.Message.GetVideoMessage().GetViewOnce(), event.SourceWebMsg != nil:
+       case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.Message.GetImageMessage().GetViewOnce(), event.Message.GetVideoMessage().GetViewOnce(), event.Message.GetAudioMessage().GetViewOnce(), event.SourceWebMsg != nil:
        case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
        case slices.Contains(own, chatJID.User):
        default:
        case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
        case slices.Contains(own, chatJID.User):
        default:
@@ -367,7 +375,8 @@ func whatsappMessage(ctx context.Context, device *store.Device, own []string, ev
 }
 
 // whatsappText returns the chat and the batch message of event, a message that
 }
 
 // whatsappText returns the chat and the batch message of event, a message that
-// the batches take. An image returns its caption until receive stores it.
+// the batches take. An image returns its caption until receive stores it. A
+// voice note returns a marker until receive transcribes it.
 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
        info := event.Info
        text := event.Message.GetConversation()
 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
        info := event.Info
        text := event.Message.GetConversation()
@@ -378,6 +387,9 @@ func whatsappText(ctx context.Context, device *store.Device, own []string, event
 
        contextInfo := extended.GetContextInfo()
        switch {
 
        contextInfo := extended.GetContextInfo()
        switch {
+       case event.Message.GetAudioMessage().GetPTT():
+               text = "(voice not downloaded)"
+               contextInfo = event.Message.GetAudioMessage().GetContextInfo()
        case event.Message.GetImageMessage() != nil:
                image := event.Message.GetImageMessage()
                text = image.GetCaption()
        case event.Message.GetImageMessage() != nil:
                image := event.Message.GetImageMessage()
                text = image.GetCaption()