--- /dev/null
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "image"
+ "image/gif"
+ "image/jpeg"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestWhatsAppImagesAreDurableBeforeAcknowledgement(t *testing.T) {
+ testCases := []struct {
+ name string
+ sender types.JID
+ }{
+ {
+ name: "a contact image keeps its caption and pixels",
+ sender: whatsappTestAna,
+ },
+ {
+ name: "an own image keeps its caption and pixels",
+ sender: whatsappTestOwn,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+ event := whatsappImageTestEvent(tc.sender, "Read this label.")
+ require.True(t, whatsapp.handle(event))
+ require.Len(t, fake.Downloads(), 1)
+ require.Same(t, event.Message.ImageMessage, fake.Downloads()[0])
+
+ restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ batch := restored.Closed()[0]
+ names := imageFiles(batch.Messages[0].Text)
+ require.Len(t, names, 1)
+ require.Equal(t, tc.sender == whatsappTestOwn, batch.Messages[0].Mine)
+ require.Equal(t, imageText(names, "Read this label."), batch.Messages[0].Text)
+ require.Equal(t, names, imageFiles(batch.Text(time.UTC)))
+
+ store := &ImageStore{dir: filepath.Join(dir, imageDirName)}
+ saved, err := store.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, data, saved)
+
+ info, err := os.Stat(filepath.Join(store.dir, names[0]))
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+ require.NotContains(t, logs.String(), "Read this label")
+ require.NotContains(t, logs.String(), "base64")
+ })
+ }
+}
+
+func TestWhatsAppImageDownloadCanOutlastNameLookup(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ whatsapp.inbox.lookupTime = 10 * time.Millisecond
+ whatsapp.inbox.imageTime = time.Second
+ fake.downloadWait = 50 * time.Millisecond
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+
+ require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Slow photo.")))
+
+ restored := whatsappTestState(t, whatsapp.state.path)
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ text := restored.Closed()[0].Messages[0].Text
+ names := imageFiles(text)
+ require.Len(t, names, 1)
+ require.Equal(t, imageText(names, "Slow photo."), text)
+
+ saved, err := whatsapp.inbox.images.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, data, saved)
+}
+
+func TestWhatsAppImageFailureKeepsTheBatchMoving(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadErr = errors.New("download failed for " + whatsappTestNumber + " and " + whatsappTestAna.User)
+ event := whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")
+ require.True(t, whatsapp.handle(event))
+ require.True(t, whatsapp.handle(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "next", "Next text.", event.Info.Timestamp)))
+
+ restored := whatsappTestState(t, filepath.Join(whatsapp.dir, whatsappStateFileName))
+ require.NoError(t, restored.CloseAll())
+ require.Len(t, restored.Closed(), 1)
+
+ messages := restored.Closed()[0].Messages
+ require.Len(t, messages, 2)
+ require.Equal(t, "(image not downloaded)\nKeep this caption.", messages[0].Text)
+ require.Equal(t, "Next text.", messages[1].Text)
+ require.Empty(t, imageFiles(restored.Closed()[0].Text(time.UTC)))
+ require.Contains(t, logs.String(), "<3>image receive failed chat=")
+ require.Contains(t, logs.String(), ": download image: media download failed")
+ require.NotContains(t, logs.String(), whatsappTestNumber)
+ require.NotContains(t, logs.String(), whatsappTestAna.User)
+ require.NotContains(t, logs.String(), "Keep this caption")
+ require.NotContains(t, logs.String(), "base64")
+}
+
+func TestWhatsAppImageDownloadErrorsKeepOnlySafeCauses(t *testing.T) {
+ mediaURL := "https://example.com/media/secret-path?auth=secret-token&hash=secret-hash"
+ directPath := "media/secret-path?auth=secret-token&hash=secret-hash"
+ testCases := []struct {
+ name string
+ err error
+ cause string
+ }{
+ {
+ name: "signed request URLs do not reach the log",
+ err: &url.Error{
+ Op: "Get",
+ URL: mediaURL,
+ Err: errors.New("connection failed"),
+ },
+ cause: "media download failed",
+ },
+ {
+ name: "invalid direct paths do not reach the log",
+ err: fmt.Errorf("media download path does not start with slash: %s", directPath),
+ cause: "media download failed",
+ },
+ {
+ name: "request preparation errors do not expose media paths",
+ err: fmt.Errorf("failed to prepare request: parse %q: invalid control character", mediaURL),
+ cause: "media download failed",
+ },
+ {
+ name: "cancellation keeps its safe cause without the URL",
+ err: fmt.Errorf("request %s: %w", mediaURL, context.Canceled),
+ cause: "context canceled",
+ },
+ {
+ name: "a deadline keeps its safe cause without the path",
+ err: fmt.Errorf("request %s: %w", directPath, context.DeadlineExceeded),
+ cause: "context deadline exceeded",
+ },
+ {
+ name: "HTTP failures keep only the status code",
+ err: whatsmeow.DownloadHTTPError{Response: &http.Response{
+ StatusCode: http.StatusForbidden,
+ Status: "403 " + mediaURL,
+ }},
+ cause: "media server returned HTTP 403",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadErr = tc.err
+
+ require.True(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this caption.")))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Equal(t, "(image not downloaded)\nKeep this caption.", whatsapp.state.Closed()[0].Messages[0].Text)
+ require.Contains(t, logs.String(), "<3>image receive failed chat=")
+ require.Contains(t, logs.String(), ": download image: "+tc.cause)
+
+ for _, secret := range []string{mediaURL, directPath, "example.com", "secret-path", "secret-token", "secret-hash", whatsappTestAna.User, "Keep this caption."} {
+ require.NotContains(t, logs.String(), secret)
+ }
+ })
+ }
+}
+
+func TestWhatsAppImageLimitsKeepMarkersWithoutStoredPixels(t *testing.T) {
+ var gifBytes bytes.Buffer
+
+ require.NoError(t, gif.Encode(&gifBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
+
+ testCases := []struct {
+ name string
+ mime string
+ size uint64
+ data []byte
+ downloads int
+ }{
+ {
+ name: "declared large images are not downloaded",
+ mime: "image/png",
+ size: imageMaxBytes + 1,
+ },
+ {
+ name: "large downloaded images become markers",
+ mime: "image/png",
+ data: bytes.Repeat([]byte{0}, imageMaxBytes+1),
+ downloads: 1,
+ },
+ {
+ name: "unsupported image types are not downloaded",
+ mime: "image/gif",
+ },
+ {
+ name: "GIF bytes cannot claim to be PNG",
+ mime: "image/png",
+ data: gifBytes.Bytes(),
+ downloads: 1,
+ },
+ {
+ name: "invalid image bytes become markers",
+ mime: "image/png",
+ data: []byte("not an image"),
+ downloads: 1,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = tc.data
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message.ImageMessage.Mimetype = new(tc.mime)
+ event.Message.ImageMessage.FileLength = new(tc.size)
+ require.True(t, whatsapp.handle(event))
+ require.Len(t, fake.Downloads(), tc.downloads)
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Len(t, whatsapp.state.Closed(), 1)
+ require.Equal(t, "(image not downloaded)", whatsapp.state.Closed()[0].Messages[0].Text)
+
+ _, err := os.Stat(whatsapp.inbox.images.dir)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ })
+ }
+}
+
+func TestWhatsAppSupportsJPEGPNGAndWebPBytes(t *testing.T) {
+ var jpegBytes bytes.Buffer
+
+ require.NoError(t, jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil))
+
+ testCases := []struct {
+ name string
+ mime string
+ data []byte
+ suffix string
+ }{
+ {
+ name: "PNG bytes keep the PNG extension",
+ mime: "image/png",
+ data: imageTestPNG(t, 2, 2),
+ suffix: ".png",
+ },
+ {
+ name: "JPEG bytes set the JPEG extension even with a PNG label",
+ mime: "image/png",
+ data: jpegBytes.Bytes(),
+ suffix: ".jpg",
+ },
+ {
+ name: "WebP bytes keep the WebP extension",
+ mime: "image/webp",
+ data: []byte{'R', 'I', 'F', 'F', 22, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P', '8', 'L', 10, 0, 0, 0, 0x2f, 0, 0, 0, 0, 0x07, 0x10, 0x11, 0x11, 0x88},
+ suffix: ".webp",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = tc.data
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message.ImageMessage.Mimetype = new(tc.mime)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ names := imageFiles(whatsapp.state.Closed()[0].Text(time.UTC))
+ require.Len(t, names, 1)
+ require.True(t, strings.HasSuffix(names[0], tc.suffix))
+
+ data, err := whatsapp.inbox.images.Read(names[0])
+ require.NoError(t, err)
+ require.Equal(t, tc.data, data)
+ })
+ }
+}
+
+func TestWhatsAppMediaMarkersNeverDownload(t *testing.T) {
+ testCases := []struct {
+ name string
+ message *waE2E.Message
+ text string
+ }{
+ {
+ name: "stickers keep a text marker",
+ message: &waE2E.Message{
+ StickerMessage: &waE2E.StickerMessage{},
+ },
+ text: "(sticker)",
+ },
+ {
+ name: "videos keep a text marker and caption",
+ message: &waE2E.Message{
+ VideoMessage: &waE2E.VideoMessage{Caption: new("Watch this.")},
+ },
+ text: "(video)\nWatch this.",
+ },
+ {
+ name: "GIF playback keeps a text marker",
+ message: &waE2E.Message{
+ VideoMessage: &waE2E.VideoMessage{GifPlayback: new(true)},
+ },
+ text: "(GIF)",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ event := whatsappImageTestEvent(whatsappTestAna, "")
+ event.Message = tc.message
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Len(t, whatsapp.state.Closed(), 1)
+ require.Equal(t, tc.text, whatsapp.state.Closed()[0].Messages[0].Text)
+ require.Empty(t, fake.Downloads())
+ })
+ }
+}
+
+func TestWhatsAppPrivateImagesAreNotDownloaded(t *testing.T) {
+ testCases := []struct {
+ name string
+ change func(event *events.Message)
+ }{
+ {
+ name: "view-once events are not read",
+ change: func(event *events.Message) {
+ event.IsViewOnce = true
+ },
+ },
+ {
+ name: "view-once image fields are not read",
+ change: func(event *events.Message) {
+ event.Message.ImageMessage.ViewOnce = new(true)
+ },
+ },
+ {
+ name: "disappearing images are not read",
+ change: func(event *events.Message) {
+ event.IsEphemeral = true
+ },
+ },
+ {
+ name: "self-chat images are not read",
+ change: func(event *events.Message) {
+ event.Info.Chat = whatsappTestOwn
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ event := whatsappImageTestEvent(whatsappTestAna, "Secret caption.")
+ tc.change(event)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+ require.Empty(t, whatsapp.state.Closed())
+ require.Empty(t, fake.Downloads())
+ })
+ }
+}
+
+func TestWhatsAppImageStateFailureDoesNotAcknowledge(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 2, 2)
+ require.NoError(t, os.Mkdir(whatsapp.state.path, 0o700))
+ require.False(t, whatsapp.handle(whatsappImageTestEvent(whatsappTestAna, "Keep this.")))
+ require.ErrorContains(t, <-whatsapp.inbox.failures, "save WhatsApp state")
+}
+
+func TestWhatsAppImagesReachTheirRootOrRoutedChild(t *testing.T) {
+ testCases := []struct {
+ name string
+ target int
+ group bool
+ }{
+ {
+ name: "private images wake the root with pixels",
+ },
+ {
+ name: "routed group images wake only their child with pixels",
+ target: 1,
+ group: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ server.reply = whatsappImageTestReply
+ test := newDaemonMessageTest(t, dir, server)
+ test.agents[0].agent.config.Model = "gpt-test-root"
+
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ data := imageTestPNG(t, 3, 2)
+ fake.downloadData = data
+ event := whatsappImageTestEvent(whatsappTestAna, "What do you see?")
+ if tc.group {
+ event.Info.Chat = whatsappTestFamily
+ event.Info.IsGroup = true
+
+ whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans")
+ require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
+ }
+
+ test.Start(t, tc.target)
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.inbox.deliver(t.Context(), test.loops[0], whatsapp.link(), true))
+ test.events[tc.target].WaitFor(t, "idle", 1)
+
+ text := test.events[tc.target].Last("work")
+ names := imageFiles(text)
+ require.Len(t, names, 1)
+ require.Contains(t, text, "[image "+names[0]+"]\nWhat do you see?")
+ require.Equal(t, 1, test.agents[tc.target].memory.Len())
+ require.Empty(t, whatsapp.state.Closed())
+
+ if tc.target != 0 {
+ require.Zero(t, test.agents[0].memory.Len())
+ require.False(t, test.loops[0].waiting())
+ }
+
+ model := test.agents[tc.target].agent.config.Model
+ turnCalls := 0
+ for index := range server.Calls() {
+ call := server.Call(index)
+ if call.body["model"] == model {
+ turnCalls++
+
+ require.Contains(t, string(call.raw), "data:image/png;base64,"+base64.StdEncoding.EncodeToString(data))
+ }
+ }
+
+ require.Equal(t, 1, turnCalls)
+ require.NotContains(t, logs.String()+test.logs.String(), "What do you see?")
+ require.NotContains(t, logs.String()+test.logs.String(), "base64")
+ })
+ }
+}
+
+func TestWhatsAppImageRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ server.reply = whatsappImageTestReply
+ first := newDaemonMessageTest(t, dir, server)
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 3, 2)
+ event := whatsappImageTestEvent(whatsappTestAna, "Keep this image.")
+ require.NoError(t, first.messages.routes.Set("whatsapp:"+whatsappTestAna.String(), "Garden"))
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ batch := whatsapp.state.Closed()[0]
+ text := batch.Text(time.UTC)
+ routed, err := first.messages.RouteWhatsApp(batch, text)
+ require.NoError(t, err)
+ require.True(t, routed)
+ first.Close(t)
+
+ second := newDaemonMessageTest(t, dir, server)
+ restored := whatsappRouteTestInbox(t, dir)
+ require.NoError(t, restored.deliver(t.Context(), second.loops[0], WhatsAppLink{}, true))
+ require.Empty(t, restored.state.Closed())
+ require.Zero(t, second.agents[0].memory.Len())
+ require.Equal(t, 1, second.agents[1].memory.Len())
+ require.True(t, second.loops[1].waiting())
+
+ message, err := second.agents[1].memory.Message(0)
+ require.NoError(t, err)
+ require.Equal(t, text, message.Text)
+ require.Len(t, imageFiles(message.Text), 1)
+ second.Start(t, 1)
+ second.events[1].WaitFor(t, "idle", 1)
+ require.Empty(t, second.Queue(t).Pending)
+
+ turnCalls := 0
+ for index := range server.Calls() {
+ call := server.Call(index)
+ if call.body["model"] == "gpt-test-garden" {
+ turnCalls++
+
+ require.Contains(t, string(call.raw), "input_image")
+ }
+ }
+
+ require.Equal(t, 1, turnCalls)
+ second.Close(t)
+
+ third := newDaemonMessageTest(t, dir, server)
+ require.Equal(t, 1, third.agents[1].memory.Len())
+ require.False(t, third.loops[1].waiting())
+}
+
+func TestWhatsAppImageMentionsAndRepliesUseImageContext(t *testing.T) {
+ whatsapp, fake := whatsappStartTestLinked(t)
+ whatsapp.client = fake
+ fake.downloadData = imageTestPNG(t, 2, 2)
+ event := whatsappImageTestEvent(whatsappTestAna, "@"+whatsappTestLID+" check "+whatsappTestNumber)
+ event.Info.Chat = whatsappTestFamily
+ event.Message.ImageMessage.ContextInfo = &waE2E.ContextInfo{
+ MentionedJID: []string{whatsappTestLID + "@lid"},
+ Participant: new(whatsappTestOwn.String()),
+ }
+
+ require.True(t, whatsapp.handle(event))
+ require.NoError(t, whatsapp.state.CloseAll())
+
+ batch := whatsapp.state.Closed()[0]
+ require.True(t, batch.Turn())
+ require.True(t, batch.Messages[0].Mention)
+ require.True(t, batch.Messages[0].Reply)
+ require.Contains(t, batch.Text(time.UTC), "@you check OWN")
+ require.NotContains(t, batch.Text(time.UTC), whatsappTestNumber)
+ require.NotContains(t, batch.Text(time.UTC), whatsappTestLID)
+}
+
+func whatsappImageTestEvent(sender types.JID, caption string) *events.Message {
+ event := whatsappTestMessage(whatsappTestAna, sender, "image", "", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
+ event.Message = &waE2E.Message{
+ ImageMessage: &waE2E.ImageMessage{
+ Mimetype: new("image/png"),
+ Caption: new(caption),
+ },
+ }
+
+ return event
+}
+
+func whatsappImageTestReply(w http.ResponseWriter, call ModelFakeCall) {
+ text := "A red test image."
+ if call.body["model"] == "gpt-test-root" || call.body["model"] == "gpt-test-garden" {
+ text = ""
+ }
+
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5))
+}
import (
"context"
+ "crypto/sha256"
"fmt"
+ "log/slog"
"slices"
"strings"
"sync"
)
// 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.
-func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups) *WhatsAppInbox {
+// 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 the text messages into batches, one chat at a time, as
+// 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.
//
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
})
}
-// receive puts the text of event into its batch and wakes Run. link gives the
-// device and the own IDs. It returns false when the state file cannot hold the
+// 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. A message that is not text, or that the batches do not take, is
-// handled with nothing to do.
+// daemon. An image that cannot be downloaded or stored gets a text marker. A
+// 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(), whatsappLookupTime)
+ ctx, cancel := context.WithTimeout(context.Background(), i.lookupTime)
defer cancel()
chat, message, found := whatsappMessage(ctx, link.device, link.own, event)
return true
}
+ if event.Message.GetImageMessage() != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), i.imageTime)
+ defer cancel()
+
+ message.Text = i.imageText(ctx, link, event, message.Text)
+ }
+
_, err := i.state.Add(chat, message, time.Now())
if err != nil {
select {
return true
}
+// imageText stores the image of event before returning its marker and caption.
+// Failures keep the caption with a text marker. The daemon log identifies the
+// chat by a short hash, not its phone number.
+func (i *WhatsAppInbox) imageText(ctx context.Context, link WhatsAppLink, event *events.Message, caption string) string {
+ 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)
+ i.logger.Error("image receive failed", "chat", fmt.Sprintf("%x", digest[:6]), "err", whatsappRedact(redact, err.Error()))
+
+ text := "(image not downloaded)"
+ if caption != "" {
+ text += "\n" + caption
+ }
+
+ return text
+ }
+
+ return imageText([]string{name}, caption)
+}
+
// caughtUpBy ends the catch-up of the connection with the number catchups, when
// no later connection started a new one.
func (i *WhatsAppInbox) caughtUpBy(catchups int) {
// whatsappMessage returns the chat and the batch message of event, or false
// when the batches do not take it. device is the linked device, and own holds
-// the own IDs. The batches take the text of a private chat or a group, from
-// another person or from the user. They do not take media, reactions, edits,
-// deletions, view-once and disappearing messages, old history, status
-// broadcasts, channels, and the chat of the user with itself.
+// the own IDs. The batches take text, images, and media markers from a private
+// chat or a group, from another person or from the user. They do not take
+// reactions, edits, deletions, view-once and disappearing messages, old
+// history, status broadcasts, channels, and the chat of the user with itself.
//
// A mention of the user becomes @you, another mention @NAME when the name is
// known, and the own number and LID become OWN, so that they never go to the
chatJID := info.Chat.ToNonAD()
switch {
case device == nil:
- case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil:
+ case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.Message.GetImageMessage().GetViewOnce(), event.Message.GetVideoMessage().GetViewOnce(), event.SourceWebMsg != nil:
case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer:
case slices.Contains(own, chatJID.User):
default:
}
// whatsappText returns the chat and the batch message of event, a message that
-// the batches take when it has text.
+// the batches take. An image returns its caption until receive stores it.
func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
info := event.Info
text := event.Message.GetConversation()
text = extended.GetText()
}
- if strings.TrimSpace(text) == "" {
- return WhatsAppChat{}, WhatsAppMessage{}, false
+ contextInfo := extended.GetContextInfo()
+ switch {
+ case event.Message.GetImageMessage() != nil:
+ image := event.Message.GetImageMessage()
+ text = image.GetCaption()
+ contextInfo = image.GetContextInfo()
+ case event.Message.GetStickerMessage() != nil:
+ text = "(sticker)"
+ contextInfo = event.Message.GetStickerMessage().GetContextInfo()
+ case event.Message.GetVideoMessage() != nil:
+ video := event.Message.GetVideoMessage()
+ text = "(video)"
+ if video.GetGifPlayback() {
+ text = "(GIF)"
+ }
+
+ if video.GetCaption() != "" {
+ text += "\n" + video.GetCaption()
+ }
+
+ contextInfo = video.GetContextInfo()
+ default:
+ if strings.TrimSpace(text) == "" {
+ return WhatsAppChat{}, WhatsAppMessage{}, false
+ }
}
- contextInfo := extended.GetContextInfo()
message := WhatsAppMessage{
ID: info.ID,
Time: info.Timestamp,