From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 03:30:30 +0000 (-0300) Subject: Receive voice notes from both chat services X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/d73a88b9e0835b1d7f0711e02db56555101a7830?hp=5dbb2e42e88e78bfc79b473b5b1dc466d8dbbf62 Receive voice notes from both chat services Owner voice notes from Telegram and accepted WhatsApp voice notes enter shared audio storage and reach the agent as text. Downloads and local transcription complete before durable message acknowledgement. Failure markers keep later messages moving, and routed transcripts use the same child queue and restart recovery as text. Other senders, view-once messages, and music attachments do not become voice input. --- diff --git a/agent.go b/agent.go index b86880e..d95a81c 100644 --- a/agent.go +++ b/agent.go @@ -90,6 +90,16 @@ func (a *Agent) Images() *ImageStore { return &ImageStore{dir: filepath.Join(filepath.Dir(root), imageDirName)} } +// Voices returns the shared data-directory audio store, also for a child. +func (a *Agent) Voices() *VoiceStore { + root := a.dir + if a.config.Parent != "" { + root = filepath.Dir(filepath.Dir(root)) + } + + return &VoiceStore{dir: filepath.Join(filepath.Dir(root), voiceDirName)} +} + // CacheKey returns the prompt cache key of every model call of the agent. func (a *Agent) CacheKey() string { return "onechat-" + a.config.ID diff --git a/daemon.go b/daemon.go index 5c94a4f..31a6714 100644 --- a/daemon.go +++ b/daemon.go @@ -90,6 +90,8 @@ func daemon(ctx context.Context, out io.Writer) (err error) { err = errors.Join(err, agents.Close()) }() + whatsapp.inbox.voice = &VoiceTranscriber{config: config.Voice} + root := agents[0] agent := root.agent schedules := root.schedules @@ -111,6 +113,8 @@ func daemon(ctx context.Context, out io.Writer) (err error) { if hasBot { telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), daemonLogger(out, "telegram: ")) + telegram.voice = &VoiceTranscriber{config: config.Voice} + logger.Info("Telegram bot started") } diff --git a/telegram.go b/telegram.go index f3b3bb2..9494c0a 100644 --- a/telegram.go +++ b/telegram.go @@ -108,6 +108,7 @@ func loadTelegramConfig(dir string) (TelegramConfig, bool, error) { func newTelegramBot(api *TelegramAPI, owner int64, offsetPath string, logger *slog.Logger) *TelegramBot { return &TelegramBot{ api: api, + voice: &VoiceTranscriber{}, owner: owner, offsetPath: offsetPath, logger: logger, @@ -158,6 +159,7 @@ func (c TelegramConfig) validate() error { // and the retries between them log nothing. type TelegramBot struct { api *TelegramAPI + voice *VoiceTranscriber owner int64 offsetPath string logger *slog.Logger @@ -399,8 +401,9 @@ func (b *TelegramBot) pause(ctx context.Context, outage *TelegramOutage, err err // /stop interrupts the turn, the line /stop NAME stops the subagent NAME, and a // line that starts with /whatsapp runs a WhatsApp command on whatsapp, whose // answers go to the chat only. Photos and image documents enter the shared -// image store with their caption. Other media keep the text-only answer. Every -// other update is ignored, and its content is not logged. +// image store with their caption. Voice notes keep their audio and local +// transcript. Other media keep the text-only answer. Every other update is +// ignored, and its content is not logged. func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp, update TelegramUpdate) error { message := update.Message if message == nil || message.Chat.Type != "private" || message.Chat.ID != b.owner || message.From == nil || message.From.ID != b.owner { @@ -412,6 +415,17 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, whatsapp *WhatsAp name, stop := stopSubagentName(message.Text) args, isWhatsApp := whatsappCommandArgs(message.Text) switch { + case message.Voice != nil: + text := telegramVoiceText(ctx, b.api, l.agent.Voices(), b.voice, message) + _, err := l.Submit(telegramSource, text) + if err != nil { + return fmt.Errorf("submit Telegram voice: %w", err) + } + + err = l.Flush(context.WithoutCancel(ctx)) + if err != nil { + return fmt.Errorf("flush Telegram voice: %w", err) + } case len(message.Photo) > 0 || message.Document != nil: text, err := telegramImageText(ctx, b.api, l.agent.Images(), message) if err != nil { diff --git a/telegram_api.go b/telegram_api.go index 508010e..f53d8d5 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -35,11 +35,11 @@ const ( // its own, because the long poll of getUpdates needs a longer limit. telegramSendTimeout = 30 * time.Second - // telegramFileLookupTimeout is the longest wait for image file details. + // telegramFileLookupTimeout is the longest wait for media file details. telegramFileLookupTimeout = 30 * time.Second - // telegramImageDownloadTimeout is the longest wait for image bytes. - telegramImageDownloadTimeout = 30 * time.Second + // telegramFileDownloadTimeout is the longest wait for image bytes. + telegramFileDownloadTimeout = 30 * time.Second // telegramTypingTimeout is the longest wait to show the typing action. telegramTypingTimeout = 30 * time.Second @@ -109,9 +109,10 @@ func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramU return updates, err } -// GetImage downloads a file through the authenticated file endpoint. Errors -// never include its token or URL, and the body is limited before storage. -func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err error) { +// GetFile downloads bounded media through the authenticated file endpoint. +// Errors never include its token or URL, and the body is limited before +// storage. +func (b *TelegramAPI) GetFile(ctx context.Context, id string, limit int64, tooLarge error) (data []byte, err error) { var file struct { Path string `json:"file_path"` Size int64 `json:"file_size"` @@ -119,34 +120,34 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err err = b.call(ctx, telegramFileLookupTimeout, "getFile", map[string]any{"file_id": id}, &file) if err != nil { - return nil, fmt.Errorf("get image file: %w", err) + return nil, fmt.Errorf("get media file: %w", err) } - if file.Size > imageMaxBytes { - return nil, errImageSize + if file.Size > limit { + return nil, tooLarge } if file.Path == "" || path.Clean(file.Path) != file.Path || strings.HasPrefix(file.Path, "/") || strings.HasPrefix(file.Path, "../") || strings.ContainsAny(file.Path, "?%#\\") { - return nil, errors.New("invalid image file path") + return nil, errors.New("invalid media file path") } - ctx, cancel := context.WithTimeout(ctx, telegramImageDownloadTimeout) + ctx, cancel := context.WithTimeout(ctx, telegramFileDownloadTimeout) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/file/bot"+b.token+"/"+file.Path, nil) if err != nil { - return nil, fmt.Errorf("make image request: %s", b.redact(err)) + return nil, fmt.Errorf("make media request: %s", b.redact(err)) } response, err := b.http.Do(request) if err != nil { - return nil, b.networkError("image download failed", err, telegramImageDownloadTimeout) + return nil, b.networkError("media download failed", err, telegramFileDownloadTimeout) } defer func() { closeErr := response.Body.Close() if closeErr != nil { - err = errors.Join(err, b.networkError("close image download", closeErr, telegramImageDownloadTimeout)) + err = errors.Join(err, b.networkError("close media download", closeErr, telegramFileDownloadTimeout)) } }() @@ -157,13 +158,13 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err } } - data, err = io.ReadAll(io.LimitReader(response.Body, imageMaxBytes+1)) + data, err = io.ReadAll(io.LimitReader(response.Body, limit+1)) if err != nil { - return nil, b.networkError("image cut off", err, telegramImageDownloadTimeout) + return nil, b.networkError("media cut off", err, telegramFileDownloadTimeout) } - if len(data) > imageMaxBytes { - return nil, errImageSize + if int64(len(data)) > limit { + return nil, tooLarge } return data, nil @@ -430,6 +431,13 @@ type TelegramMessage struct { Caption string `json:"caption"` Photo []TelegramPhoto `json:"photo"` Document *TelegramDocument `json:"document"` + Voice *TelegramVoice `json:"voice"` +} + +// TelegramVoice identifies a voice note in the authenticated file API. +type TelegramVoice struct { + FileID string `json:"file_id"` + FileSize int64 `json:"file_size"` } // TelegramPhoto is one supplied size of a photo. diff --git a/telegram_image.go b/telegram_image.go index 9b73341..71b2227 100644 --- a/telegram_image.go +++ b/telegram_image.go @@ -57,7 +57,7 @@ func telegramImageText(ctx context.Context, api *TelegramAPI, store *ImageStore, return "", errImageSize } - data, err := api.GetImage(ctx, id) + data, err := api.GetFile(ctx, id, imageMaxBytes, errImageSize) if err != nil { return "", fmt.Errorf("download image: %w", err) } diff --git a/telegram_voice.go b/telegram_voice.go new file mode 100644 index 0000000..04cb8ec --- /dev/null +++ b/telegram_voice.go @@ -0,0 +1,21 @@ +package main + +import "context" + +// telegramVoiceText keeps a voice note or a fixed failure marker. Download and +// decode failures do not block later updates. Only owner messages reach it. +func telegramVoiceText(ctx context.Context, api *TelegramAPI, store *VoiceStore, transcriber *VoiceTranscriber, message *TelegramMessage) string { + text := "(voice not downloaded)" + if message.Voice.FileSize <= voiceMaxBytes { + data, err := api.GetFile(ctx, message.Voice.FileID, voiceMaxBytes, errVoiceSize) + if err == nil { + text = transcriber.Text(ctx, store, data) + } + } + + if message.Caption != "" { + text += "\n" + message.Caption + } + + return text +} diff --git a/telegram_voice_test.go b/telegram_voice_test.go new file mode 100644 index 0000000..ee5ee3f --- /dev/null +++ b/telegram_voice_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTelegramVoiceNotesReachTheAgentAsStoredText(t *testing.T) { + server := newModelFake(t) + test := newTestTurnLoop(t, server) + telegram := newTelegramFake(t) + bot, logs := telegramStartTestBot(t, telegram) + _, bot.voice = voiceTestInput(t, voiceTestScript) + audio := []byte("made-up Telegram voice") + telegram.Image("voice", audio) + + message := telegramOwnerMessage("") + message.Voice = &TelegramVoice{FileID: "voice"} + message.Caption = "A voice caption." + require.NoError(t, bot.handle(t.Context(), test.loop, nil, TelegramUpdate{Message: message})) + test.events.WaitFor(t, "idle", 1) + + logged, err := test.memory.Message(0) + require.NoError(t, err) + require.Contains(t, logged.Text, "[voice ") + require.Contains(t, logged.Text, "A made-up voice transcript.\nA voice caption.") + + files, err := os.ReadDir(test.agent.Voices().dir) + require.NoError(t, err) + require.Len(t, files, 1) + + saved, err := os.ReadFile(filepath.Join(test.agent.Voices().dir, files[0].Name())) + require.NoError(t, err) + require.Equal(t, audio, saved) + require.Contains(t, string(server.Call(0).raw), "A made-up voice transcript.") + require.NotContains(t, logs.String(), "A made-up") + require.NotContains(t, logs.String(), "A voice caption") +} + +func TestTelegramVoiceFailuresKeepFollowingTextMoving(t *testing.T) { + testCases := []struct { + name string + size int64 + file string + marker string + }{ + { + name: "oversize metadata prevents a download", + size: voiceMaxBytes + 1, + file: "voice", + marker: "(voice not downloaded)", + }, + { + name: "download failure becomes text", + file: "absent", + marker: "(voice not downloaded)", + }, + { + name: "missing model preserves stored audio", + file: "voice", + marker: "transcriber or model is not available", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test := newTestTurnLoop(t, newModelFake(t)) + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + bot.voice.config.Model = filepath.Join(t.TempDir(), "missing") + + telegram.Image("voice", []byte("made-up voice")) + + message := telegramOwnerMessage("") + message.Voice = &TelegramVoice{ + FileID: tc.file, + FileSize: tc.size, + } + require.NoError(t, bot.handle(t.Context(), test.loop, nil, TelegramUpdate{Message: message})) + require.NoError(t, bot.handle(t.Context(), test.loop, nil, TelegramUpdate{Message: telegramOwnerMessage("Next text.")})) + require.NoError(t, test.loop.Flush(t.Context())) + + logged, err := test.memory.Message(0) + require.NoError(t, err) + require.Contains(t, logged.Text, tc.marker) + + found := false + for n := 0; n < test.memory.Len(); n++ { + logged, err = test.memory.Message(n) + require.NoError(t, err) + + found = found || logged.Text == "Next text." + } + + require.True(t, found) + }) + } +} + +func TestTelegramIgnoresVoiceNotesFromOtherSenders(t *testing.T) { + test := newTestTurnLoop(t, newModelFake(t)) + telegram := newTelegramFake(t) + bot, _ := telegramStartTestBot(t, telegram) + message := telegramOwnerMessage("") + message.Voice = &TelegramVoice{FileID: "voice"} + message.From.ID++ + require.NoError(t, bot.handle(t.Context(), test.loop, nil, TelegramUpdate{Message: message})) + require.Empty(t, telegram.Calls()) + require.Zero(t, test.memory.Len()) +} diff --git a/voice_helper_test.go b/voice_helper_test.go index 326d7a8..9408415 100644 --- a/voice_helper_test.go +++ b/voice_helper_test.go @@ -9,6 +9,12 @@ import ( "github.com/stretchr/testify/require" ) +const voiceTestScript = ` +while [ "$1" != --output-file ]; do shift; done +shift +printf 'A made-up voice transcript.\n' > "$1.txt" +` + func voiceTestInput(t *testing.T, script string) (*VoiceStore, *VoiceTranscriber) { t.Helper() diff --git a/voice_transcriber_test.go b/voice_transcriber_test.go index 58df9ea..48732f7 100644 --- a/voice_transcriber_test.go +++ b/voice_transcriber_test.go @@ -107,6 +107,46 @@ func TestVoiceUnavailableAndStorageFailuresDoNotStopIntake(t *testing.T) { require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, []byte("audio"))) } +func TestVoiceBackupsKeepAudioButNotTemporaryTranscripts(t *testing.T) { + root := t.TempDir() + store := &VoiceStore{dir: filepath.Join(root, voiceDirName)} + name, err := store.Save([]byte("made-up backed-up audio")) + require.NoError(t, err) + + temp := filepath.Join(store.dir, ".transcript-in-progress") + require.NoError(t, os.Mkdir(temp, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(temp, "text.txt"), []byte("private temporary text"), 0o600)) + + target := t.TempDir() + require.NoError(t, backupCopy(t.Context(), root, target, filepath.Join(t.TempDir(), "backup.tar.gz"))) + require.FileExists(t, filepath.Join(target, voiceDirName, name)) + require.NoDirExists(t, filepath.Join(target, voiceDirName, ".transcript-in-progress")) + + archive := filepath.Join(t.TempDir(), "voice.tar.gz") + _, err = backupArchive(t.Context(), target, archive) + require.NoError(t, err) + + restored := t.TempDir() + require.NoError(t, backupExtract(t.Context(), archive, restored)) + + audio, err := os.ReadFile(filepath.Join(restored, voiceDirName, name)) + require.NoError(t, err) + require.Equal(t, []byte("made-up backed-up audio"), audio) + + info, err := os.Stat(filepath.Join(restored, voiceDirName, name)) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestVoiceConfigReadsExplicitLocalPaths(t *testing.T) { + dir := t.TempDir() + configWriteTest(t, dir, `{"voice":{"transcriber":"local-whisper","model":"model.bin"}}`) + + config, err := loadConfig(dir) + require.NoError(t, err) + require.Equal(t, VoiceConfig{Transcriber: "local-whisper", Model: "model.bin"}, config.Voice) +} + func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) { _, transcriber := voiceTestInput(t, "exit 0") valid := transcriber.config diff --git a/whatsapp_inbox.go b/whatsapp_inbox.go index cff43c7..7d4e9e4 100644 --- a/whatsapp_inbox.go +++ b/whatsapp_inbox.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "slices" "strings" "sync" @@ -40,6 +41,8 @@ func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *What 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), @@ -73,6 +76,8 @@ type WhatsAppInbox struct { clock func() time.Time groups *WhatsAppGroups images *ImageStore + voices *VoiceStore + voice *VoiceTranscriber logger *slog.Logger wake chan struct{} failures chan error @@ -243,6 +248,8 @@ func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *e 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()) @@ -356,7 +363,7 @@ func whatsappMessage(ctx context.Context, device *store.Device, own []string, ev 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: @@ -378,6 +385,9 @@ func whatsappText(ctx context.Context, device *store.Device, own []string, event 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() diff --git a/whatsapp_voice.go b/whatsapp_voice.go new file mode 100644 index 0000000..5843ac4 --- /dev/null +++ b/whatsapp_voice.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" +) + +// whatsappVoiceDownloadTimeout bounds one voice-note media download. +const whatsappVoiceDownloadTimeout = 2 * time.Minute + +// whatsappVoice stores and transcribes accepted voice notes before their batch +// is acknowledged. Failure markers reveal no URLs, keys, or process output. +func whatsappVoice(ctx context.Context, client WhatsAppClient, store *VoiceStore, transcriber *VoiceTranscriber, audio *waE2E.AudioMessage) string { + if audio.GetFileLength() > voiceMaxBytes || client == nil { + return "(voice not downloaded)" + } + + downloadCtx, cancel := context.WithTimeout(ctx, whatsappVoiceDownloadTimeout) + defer cancel() + + data, err := client.Download(downloadCtx, audio) + if err != nil { + return "(voice not downloaded)" + } + + return transcriber.Text(ctx, store, data) +} diff --git a/whatsapp_voice_test.go b/whatsapp_voice_test.go new file mode 100644 index 0000000..305afdf --- /dev/null +++ b/whatsapp_voice_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +func TestWhatsAppVoiceNotesAreDurableBeforeAcknowledgement(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + whatsapp.client = fake + _, whatsapp.inbox.voice = voiceTestInput(t, voiceTestScript) + audio := []byte("made-up WhatsApp voice") + fake.downloadData = audio + event := whatsappVoiceTestEvent(whatsappTestAna) + require.True(t, whatsapp.handle(event)) + require.NoError(t, whatsapp.state.CloseAll()) + + restored := whatsappTestState(t, whatsapp.state.path) + require.Len(t, restored.Closed(), 1) + + text := restored.Closed()[0].Messages[0].Text + require.Contains(t, text, "[voice ") + require.Contains(t, text, "A made-up voice transcript.") + + files, err := os.ReadDir(whatsapp.inbox.voices.dir) + require.NoError(t, err) + require.Len(t, files, 1) + + stored, err := os.ReadFile(filepath.Join(whatsapp.inbox.voices.dir, files[0].Name())) + require.NoError(t, err) + require.Equal(t, audio, stored) + require.True(t, whatsapp.handle(event)) + require.NoError(t, whatsapp.state.CloseAll()) + require.Len(t, whatsapp.state.Closed()[0].Messages, 1) +} + +func TestWhatsAppVoiceRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T) { + dir := t.TempDir() + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) + } + first := newDaemonMessageTest(t, dir, server) + whatsappTestSession(t, dir, true) + + whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock()) + whatsapp.client = fake + _, whatsapp.inbox.voice = voiceTestInput(t, voiceTestScript) + fake.downloadData = []byte("made-up routed voice") + event := whatsappVoiceTestEvent(whatsappTestAna) + event.Info.Chat = whatsappTestFamily + event.Info.IsGroup = true + + whatsapp.groups.set(whatsappTestFamily.String(), "Garden plans") + require.NoError(t, first.messages.routes.Set("whatsapp:"+whatsappTestFamily.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()) + + message, err := second.agents[1].memory.Message(0) + require.NoError(t, err) + require.Equal(t, text, message.Text) + require.Contains(t, text, "[voice ") + require.Contains(t, text, "A made-up voice transcript.") + require.Equal(t, whatsapp.inbox.voices.dir, second.agents[1].agent.Voices().dir) + second.Start(t, 1) + second.events[1].WaitFor(t, "idle", 1) + require.Contains(t, string(server.Call(0).raw), "A made-up voice transcript.") + require.NotContains(t, logs.String()+second.logs.String(), "A made-up voice transcript") + 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 TestWhatsAppVoiceFailuresKeepLaterMessagesMoving(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + whatsapp.client = fake + fake.downloadWait = 3 * time.Minute + + require.True(t, whatsapp.handle(whatsappVoiceTestEvent(whatsappTestAna))) + + fake.downloadWait = 0 + fake.downloadErr = errors.New("secret URL and key") + event := whatsappVoiceTestEvent(whatsappTestAna) + event.Info.ID = "failed" + require.True(t, whatsapp.handle(event)) + require.True(t, whatsapp.handle(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "next", "Next text.", event.Info.Timestamp))) + require.NoError(t, whatsapp.state.CloseAll()) + + messages := whatsapp.state.Closed()[0].Messages + require.Len(t, messages, 3) + require.Equal(t, "(voice not downloaded)", messages[0].Text) + require.Equal(t, "(voice not downloaded)", messages[1].Text) + require.Equal(t, "Next text.", messages[2].Text) + }) +} + +func TestWhatsAppSkipsViewOnceAndNonVoiceAudio(t *testing.T) { + whatsapp, fake := whatsappStartTestLinked(t) + whatsapp.client = fake + event := whatsappVoiceTestEvent(whatsappTestAna) + event.Message.AudioMessage.ViewOnce = new(true) + require.True(t, whatsapp.handle(event)) + + event.Message.AudioMessage.ViewOnce = new(false) + event.Message.AudioMessage.PTT = new(false) + require.True(t, whatsapp.handle(event)) + require.Empty(t, fake.Downloads()) + require.NoError(t, whatsapp.state.CloseAll()) + require.Empty(t, whatsapp.state.Closed()) +} + +func whatsappVoiceTestEvent(sender types.JID) *events.Message { + event := whatsappTestMessage(whatsappTestAna, sender, "voice", "", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)) + event.Message = &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ + PTT: new(true), + Mimetype: new("audio/ogg; codecs=opus"), + }} + + return event +}