From 1a4f7d3388ec8fbee63aa0b7898c64994d859d30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 03:28:35 +0000 Subject: [PATCH] feat: add ack reaction defaults --- CHANGELOG.md | 1 + docs/configuration.md | 18 +++++- docs/discord.md | 3 + docs/slack.md | 3 + docs/telegram.md | 1 + src/config/config.test.ts | 51 ++++++++++++++++ src/config/defaults.ts | 26 ++++++++ src/config/io.ts | 11 +++- src/config/schema.ts | 6 ++ src/config/types.ts | 4 ++ src/config/zod-schema.ts | 4 ++ src/discord/monitor.ts | 23 ++++++++ src/slack/monitor.tool-result.test.ts | 85 ++++++++++++++++++++++----- src/slack/monitor.ts | 27 +++++++++ src/telegram/bot.test.ts | 40 +++++++++++++ src/telegram/bot.ts | 40 +++++++++++-- 16 files changed, 318 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcf88493..1e7dfa2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixes - Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. +- Auto-reply: add configurable ack reactions for inbound messages (default πŸ‘€ or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178. - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth. - CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup). diff --git a/docs/configuration.md b/docs/configuration.md index bde5740f9..64766931f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -131,7 +131,7 @@ rotation order used for failover. Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly): -- `messages.responsePrefix` from `identity.emoji` +- `messages.ackReaction` from `identity.emoji` (falls back to πŸ‘€) - `routing.groupChat.mentionPatterns` from `identity.name` (so β€œ@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) ```json5 @@ -477,13 +477,15 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime. ### `messages` -Controls inbound/outbound prefixes. +Controls inbound/outbound prefixes and optional ack reactions. ```json5 { messages: { messagePrefix: "[clawdbot]", - responsePrefix: "🦞" + responsePrefix: "🦞", + ackReaction: "πŸ‘€", + ackReactionScope: "group-mentions" } } ``` @@ -491,6 +493,16 @@ Controls inbound/outbound prefixes. `responsePrefix` is applied to **all outbound replies** (tool summaries, block streaming, final replies) across providers unless already present. +`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages +on providers that support reactions (Slack/Discord/Telegram). Defaults to the +configured `identity.emoji` when set, otherwise `"πŸ‘€"`. Set it to `""` to disable. + +`ackReactionScope` controls when reactions fire: +- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned +- `group-all`: all group/room messages +- `direct`: direct messages only +- `all`: all messages + ### `talk` Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset. diff --git a/docs/discord.md b/docs/discord.md index a96bfff0b..db76e325e 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -203,6 +203,9 @@ Notes: } ``` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + - `dm.enabled`: set `false` to ignore all DMs (default `true`). - `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. - `dm.groupEnabled`: enable group DMs (default `false`). diff --git a/docs/slack.md b/docs/slack.md index d68d4e1a7..c8154266d 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -180,6 +180,9 @@ Tokens can also be supplied via env vars: - `SLACK_BOT_TOKEN` - `SLACK_APP_TOKEN` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `slack:channel:` sessions. diff --git a/docs/telegram.md b/docs/telegram.md index 45c83afc4..7d0271e96 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -38,6 +38,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. - Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. + - Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` β†’ `telegram.groups."*".requireMention` β†’ default `true`. Example config: diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 88de32c84..cef464679 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -87,6 +87,57 @@ describe("config identity defaults", () => { }); }); + it("defaults ackReaction to identity emoji", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + identity: { name: "Samantha", theme: "helpful sloth", emoji: "πŸ¦₯" }, + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("πŸ¦₯"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + + it("defaults ackReaction to πŸ‘€ when identity is missing", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("πŸ‘€"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + it("does not override explicit values", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 11a23699a..cda653938 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -54,6 +54,32 @@ export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig { return mutated ? next : cfg; } +export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig { + const messages = cfg.messages; + const hasAckReaction = messages?.ackReaction !== undefined; + const hasAckScope = messages?.ackReactionScope !== undefined; + if (hasAckReaction && hasAckScope) return cfg; + + const fallbackEmoji = cfg.identity?.emoji?.trim() || "πŸ‘€"; + const nextMessages = { ...(messages ?? {}) }; + let mutated = false; + + if (!hasAckReaction) { + nextMessages.ackReaction = fallbackEmoji; + mutated = true; + } + if (!hasAckScope) { + nextMessages.ackReactionScope = "group-mentions"; + mutated = true; + } + + if (!mutated) return cfg; + return { + ...cfg, + messages: nextMessages, + }; +} + export function applySessionDefaults( cfg: ClawdbotConfig, options: SessionDefaultsOptions = {}, diff --git a/src/config/io.ts b/src/config/io.ts index 878dc0cc7..9ce2f72e5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -11,6 +11,7 @@ import { import { applyIdentityDefaults, applyLoggingDefaults, + applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, @@ -117,7 +118,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const cfg = applyModelDefaults( applySessionDefaults( applyLoggingDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), + applyMessageDefaults( + applyIdentityDefaults(validated.data as ClawdbotConfig), + ), ), ), ); @@ -148,7 +151,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey( - applyModelDefaults(applySessionDefaults({})), + applyModelDefaults(applySessionDefaults(applyMessageDefaults({}))), ); const legacyIssues: LegacyConfigIssue[] = []; return { @@ -205,7 +208,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: true, config: applyTalkApiKey( applyModelDefaults( - applySessionDefaults(applyLoggingDefaults(validated.config)), + applySessionDefaults( + applyLoggingDefaults(applyMessageDefaults(validated.config)), + ), ), ), issues: [], diff --git a/src/config/schema.ts b/src/config/schema.ts index ab582bab9..9b4ff37e2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -97,6 +97,8 @@ const FIELD_LABELS: Record = { "ui.seamColor": "Accent Color", "browser.controlUrl": "Browser Control URL", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", "talk.apiKey": "Talk API Key", "telegram.botToken": "Telegram Bot Token", "discord.token": "Discord Bot Token", @@ -131,6 +133,10 @@ const FIELD_HELP: Record = { "Ordered fallback image models (provider/model).", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", + "messages.ackReaction": + "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', }; const FIELD_PLACEHOLDERS: Record = { diff --git a/src/config/types.ts b/src/config/types.ts index 9e8feb291..e1aa82da0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -449,6 +449,10 @@ export type RoutingConfig = { export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") + /** Emoji reaction used to acknowledge inbound messages (empty disables). */ + ackReaction?: string; + /** When to send ack reactions. Default: "group-mentions". */ + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 51cd99726..7c6116153 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -150,6 +150,10 @@ const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), + ackReactionScope: z + .enum(["group-mentions", "group-all", "direct", "all"]) + .optional(), }) .optional(); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 35c21310c..335bec12a 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -146,6 +146,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, @@ -410,6 +412,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; } + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isGuildMessage || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isGuildMessage) return false; + if (!resolvedRequireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction()) { + message.react(ackReaction).catch((err) => { + logVerbose( + `discord react failed for channel ${message.channelId}: ${String(err)}`, + ); + }); + } const fromLabel = isDirectMessage ? buildDirectLabel(message) diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 4a19ca8fc..39ca6d9c5 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -5,6 +5,7 @@ import { monitorSlackProvider } from "./monitor.js"; const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); +const reactMock = vi.fn(); let config: Record = {}; const getSlackHandlers = () => ( @@ -12,6 +13,8 @@ const getSlackHandlers = () => __slackHandlers?: Map Promise>; } ).__slackHandlers; +const getSlackClient = () => + (globalThis as { __slackClient?: Record }).__slackClient; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -39,20 +42,25 @@ vi.mock("@slack/bolt", () => { const handlers = new Map Promise>(); (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; + const client = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + reactions: { + add: (...args: unknown[]) => reactMock(...args), + }, + }; + (globalThis as { __slackClient?: typeof client }).__slackClient = client; class App { - client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - }; + client = client; event(name: string, handler: (args: unknown) => Promise) { handlers.set(name, handler); } @@ -76,13 +84,18 @@ async function waitForEvent(name: string) { beforeEach(() => { config = { - messages: { responsePrefix: "PFX" }, + messages: { + responsePrefix: "PFX", + ackReaction: "πŸ‘€", + ackReactionScope: "group-mentions", + }, slack: { dm: { enabled: true }, groupDm: { enabled: false } }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); updateLastRouteMock.mockReset(); + reactMock.mockReset(); }); describe("monitorSlackProvider tool results", () => { @@ -201,4 +214,48 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); }); + + it("reacts to mention-gated room messages when ackReaction is enabled", async () => { + replyMock.mockResolvedValue(undefined); + const client = getSlackClient(); + if (!client) throw new Error("Slack client not registered"); + const conversations = client.conversations as { + info: ReturnType; + }; + conversations.info.mockResolvedValueOnce({ + channel: { name: "general", is_channel: true }, + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "<@bot-user> hello", + ts: "456", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(reactMock).toHaveBeenCalledWith({ + channel: "C1", + timestamp: "456", + name: "πŸ‘€", + }); + }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index e8509f774..7f54f2d5b 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -30,6 +30,7 @@ import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; +import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -384,6 +385,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); const textLimit = resolveTextChunkLimit(cfg, "slack"); const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; @@ -628,6 +631,30 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isRoom || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isRoom) return false; + if (!channelConfig?.requireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && message.ts) { + reactSlackMessage(message.channel, message.ts, ackReaction, { + token: botToken, + client: app.client, + }).catch((err) => { + logVerbose( + `slack react failed for channel ${message.channel}: ${String(err)}`, + ); + }); + } const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b9005ecc4..c3c971fdd 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -25,12 +25,14 @@ const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; sendMessage: typeof sendMessageSpy; sendAnimation: typeof sendAnimationSpy; sendPhoto: typeof sendPhotoSpy; @@ -38,6 +40,7 @@ type ApiStub = { const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, sendMessage: sendMessageSpy, sendAnimation: sendAnimationSpy, sendPhoto: sendPhotoSpy, @@ -74,6 +77,7 @@ describe("createTelegramBot", () => { loadWebMedia.mockReset(); sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); }); it("installs grammY throttler", () => { @@ -178,6 +182,42 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); }); + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + onSpy.mockReset(); + setMessageReactionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { ackReaction: "πŸ‘€", ackReactionScope: "group-mentions" }, + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert hello", + date: 1736380800, + message_id: 123, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [ + { type: "emoji", emoji: "πŸ‘€" }, + ]); + }); + it("skips group messages when requireMention is enabled and no mention matches", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index f6f03ceec..8af9d90fd 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -73,6 +73,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { const textLimit = resolveTextChunkLimit(cfg, "telegram"); const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); @@ -181,11 +183,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { } } - // React to acknowledge message receipt - ctx.react("✍️").catch((err) => { - logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`); - }); - const media = await resolveMedia( ctx, mediaMaxBytes, @@ -200,6 +197,39 @@ export function createTelegramBot(opts: TelegramBotOptions) { "" ).trim(); if (!rawBody) return; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return !isGroup; + if (ackReactionScope === "group-all") return isGroup; + if (ackReactionScope === "group-mentions") { + if (!isGroup) return false; + if (!resolveGroupRequireMention(chatId)) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && msg.message_id) { + const api = bot.api as unknown as { + setMessageReaction?: ( + chatId: number | string, + messageId: number, + reactions: Array<{ type: "emoji"; emoji: string }>, + ) => Promise; + }; + if (typeof api.setMessageReaction === "function") { + api + .setMessageReaction(chatId, msg.message_id, [ + { type: "emoji", emoji: ackReaction }, + ]) + .catch((err) => { + logVerbose( + `telegram react failed for chat ${chatId}: ${String(err)}`, + ); + }); + } + } const replySuffix = replyTarget ? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]` : "";