fix(telegram): voice-note tag defaults (#188, thanks @manmal)

This commit is contained in:
Peter Steinberger
2026-01-08 03:13:54 +00:00
parent 2972fce02c
commit 15379dedf0
6 changed files with 109 additions and 3 deletions

View File

@@ -95,6 +95,7 @@
- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
- Telegram: isolate forum topic transcripts per thread and validate Gemini turn ordering in multi-topic sessions. Thanks @hsrvc for PR #407.
- Telegram: render Telegram-safe HTML for outbound formatting and fall back to plain text on parse errors. Thanks @RandyVentures for PR #435.
- Telegram: add `[[audio_as_voice]]` tag to send audio as voice notes (audio files remain default); docs updated. Thanks @manmal for PR #188.
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
- Auto-reply: require slash for control commands to avoid false triggers in normal text.

View File

@@ -153,6 +153,15 @@ Telegram supports optional threaded replies via tags:
Controlled by `telegram.replyToMode`:
- `first` (default), `all`, `off`.
## Audio messages (voice vs file)
Telegram distinguishes **voice notes** (round bubble) from **audio files** (metadata card).
Clawdbot defaults to audio files for backward compatibility.
To force a voice note bubble in agent replies, include this tag anywhere in the reply:
- `[[audio_as_voice]]` — send audio as a voice note instead of a file.
The tag is stripped from the delivered text. Other providers ignore this tag.
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the

View File

@@ -23,6 +23,7 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { extractAudioTag } from "./audio-tags.js";
import { createFollowupRunner } from "./followup-runner.js";
import {
enqueueFollowupRun,
@@ -30,14 +31,12 @@ import {
type QueueSettings,
scheduleFollowupDrain,
} from "./queue.js";
import { extractAudioTag } from "./audio-tags.js";
import {
applyReplyTagsToPayload,
applyReplyThreading,
filterMessagingToolDuplicates,
isRenderablePayload,
} from "./reply-payloads.js";
import { extractReplyToTag } from "./reply-tags.js";
import {
createReplyToModeFilter,
resolveReplyToMode,
@@ -341,6 +340,7 @@ export async function runReplyAgent(params: {
const hasMedia =
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
return;
const blockPayload: ReplyPayload = applyReplyToMode({

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { extractAudioTag } from "./audio-tags.js";
describe("extractAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = extractAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hasTag).toBe(true);
expect(result.cleaned).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = extractAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hasTag).toBe(false);
expect(result.cleaned).toBe("");
});
it("removes tag-only messages", () => {
const result = extractAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.cleaned).toBe("");
});
});

View File

@@ -158,6 +158,77 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("9");
});
it("sends audio media as files by default", async () => {
const chatId = "123";
const sendAudio = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: chatId },
});
const sendVoice = vi.fn().mockResolvedValue({
message_id: 11,
chat: { id: chatId },
});
const api = { sendAudio, sendVoice } as unknown as {
sendAudio: typeof sendAudio;
sendVoice: typeof sendVoice;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/mpeg",
fileName: "clip.mp3",
});
await sendMessageTelegram(chatId, "caption", {
token: "tok",
api,
mediaUrl: "https://example.com/clip.mp3",
});
expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "caption",
});
expect(sendVoice).not.toHaveBeenCalled();
});
it("sends voice messages when asVoice is true and preserves thread params", async () => {
const chatId = "-1001234567890";
const sendAudio = vi.fn().mockResolvedValue({
message_id: 12,
chat: { id: chatId },
});
const sendVoice = vi.fn().mockResolvedValue({
message_id: 13,
chat: { id: chatId },
});
const api = { sendAudio, sendVoice } as unknown as {
sendAudio: typeof sendAudio;
sendVoice: typeof sendVoice;
};
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from("voice"),
contentType: "audio/ogg",
fileName: "note.ogg",
});
await sendMessageTelegram(chatId, "voice note", {
token: "tok",
api,
mediaUrl: "https://example.com/note.ogg",
asVoice: true,
messageThreadId: 271,
replyToMessageId: 500,
});
expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "voice note",
message_thread_id: 271,
reply_to_message_id: 500,
});
expect(sendAudio).not.toHaveBeenCalled();
});
it("includes message_thread_id for forum topic messages", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -317,7 +317,7 @@ describe("partial reply gating", () => {
undefined,
{},
);
expect(allowed).toEqual({ text: "ok" });
expect(allowed).toMatchObject({ text: "ok", audioAsVoice: false });
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});