From 0e1dcf9cb4f55ea66d58f0f7dc0dc2149f47cd7b Mon Sep 17 00:00:00 2001 From: Bohdan Podvirnyi Date: Tue, 13 Jan 2026 21:34:40 +0200 Subject: [PATCH] feat: added capability for clawdbot to react --- docs/channels/telegram.md | 44 ++++++ src/agents/system-prompt.ts | 28 ++++ src/agents/tools/telegram-actions.test.ts | 73 +++++++++- src/agents/tools/telegram-actions.ts | 15 ++- src/config/types.ts | 6 +- src/config/zod-schema.ts | 3 +- src/telegram/bot.test.ts | 155 +++++++++++++++++----- src/telegram/bot.ts | 29 ++-- src/telegram/monitor.ts | 20 +++ src/telegram/reaction-level.test.ts | 117 ++++++++++++++++ src/telegram/reaction-level.ts | 65 +++++++++ src/telegram/webhook.ts | 4 + 12 files changed, 503 insertions(+), 56 deletions(-) create mode 100644 src/telegram/reaction-level.test.ts create mode 100644 src/telegram/reaction-level.ts diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 7cd6c45ea..74e4a24f0 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -297,6 +297,48 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti - Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled). +## Reaction notifications + +**How reactions work:** +Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot: + +1. Receives the `message_reaction` update from Telegram API +2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"` +3. Enqueues the system event using the **same session key** as regular messages +4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context + +The agent sees reactions as **system notifications** in the conversation history, not as message metadata. + +**Configuration:** +- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications + - `"off"` — ignore all reactions (default when not set) + - `"all"` — notify for all reactions + +- `channels.telegram.reactionLevel`: Controls agent's reaction capability + - `"off"` — agent cannot react to messages + - `"ack"` — bot sends acknowledgment reactions (👀 while processing) + - `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges) + - `"extensive"` — agent can react liberally when appropriate + +**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together. + +**Example config:** +```json5 +{ + channels: { + telegram: { + reactionNotifications: "all", // See all reactions + reactionLevel: "minimal" // Agent can react sparingly + } + } +} +``` + +**Requirements:** +- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot) +- For webhook mode, reactions are included in the webhook `allowed_updates` +- For polling mode, reactions are included in the `getUpdates` `allowed_updates` + ## Delivery targets (CLI/cron) - Use a chat id (`123456789`) or a username (`@name`) as the target. - Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`. @@ -360,6 +402,8 @@ Provider options: - `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. +- `channels.telegram.reactionNotifications`: `off | all` — control which reactions trigger system events (default: `off` when not set). +- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `off` when not set). Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 70563cd66..650620410 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -43,6 +43,11 @@ export function buildAgentSystemPrompt(params: { defaultLevel: "on" | "off"; }; }; + /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ + reactionGuidance?: { + level: "minimal" | "extensive"; + channel: string; + }; }) { const coreToolSummaries: Record = { read: "Read file contents", @@ -351,6 +356,29 @@ export function buildAgentSystemPrompt(params: { if (extraSystemPrompt) { lines.push("## Group Chat Context", extraSystemPrompt, ""); } + if (params.reactionGuidance) { + const { level, channel } = params.reactionGuidance; + const guidanceText = + level === "minimal" + ? [ + `Reactions are enabled for ${channel} in MINIMAL mode.`, + "React ONLY when truly relevant:", + "- Acknowledge important user requests or confirmations", + "- Express genuine sentiment (humor, appreciation) sparingly", + "- Avoid reacting to routine messages or your own replies", + "Guideline: at most 1 reaction per 5-10 exchanges.", + ].join("\n") + : [ + `Reactions are enabled for ${channel} in EXTENSIVE mode.`, + "Feel free to react liberally:", + "- Acknowledge messages with appropriate emojis", + "- Express sentiment and personality through reactions", + "- React to interesting content, humor, or notable events", + "- Use reactions to confirm understanding or agreement", + "Guideline: react whenever it feels natural.", + ].join("\n"); + lines.push("## Reactions", guidanceText, ""); + } if (reasoningHint) { lines.push("## Reasoning Format", reasoningHint, ""); } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 134759464..8f4bb2a9b 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -33,9 +33,30 @@ describe("handleTelegramAction", () => { } }); - it("adds reactions", async () => { + it("adds reactions when reactionLevel is minimal", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as ClawdbotConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); + }); + + it("adds reactions when reactionLevel is extensive", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -56,7 +77,7 @@ describe("handleTelegramAction", () => { it("removes reactions on empty emoji", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -77,7 +98,7 @@ describe("handleTelegramAction", () => { it("removes reactions when remove flag set", async () => { const cfg = { - channels: { telegram: { botToken: "tok" } }, + channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } }, } as ClawdbotConfig; await handleTelegramAction( { @@ -97,10 +118,48 @@ describe("handleTelegramAction", () => { ); }); - it("respects reaction gating", async () => { + it("blocks reactions when reactionLevel is off", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "off" } }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/); + }); + + it("blocks reactions when reactionLevel is ack (default)", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "ack" } }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/); + }); + + it("also respects legacy actions.reactions gating", async () => { const cfg = { channels: { - telegram: { botToken: "tok", actions: { reactions: false } }, + telegram: { + botToken: "tok", + reactionLevel: "minimal", + actions: { reactions: false }, + }, }, } as ClawdbotConfig; await expect( @@ -113,7 +172,7 @@ describe("handleTelegramAction", () => { }, cfg, ), - ).rejects.toThrow(/Telegram reactions are disabled/); + ).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/); }); it("sends a text message", async () => { diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index c340954aa..5ffc52515 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { deleteMessageTelegram, reactMessageTelegram, @@ -82,8 +83,20 @@ export async function handleTelegramAction( const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions); if (action === "react") { + // Check reaction level first + const reactionLevelInfo = resolveTelegramReactionLevel({ + cfg, + accountId: accountId ?? undefined, + }); + if (!reactionLevelInfo.agentReactionsEnabled) { + throw new Error( + `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` + + `Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`, + ); + } + // Also check the existing action gate for backward compatibility if (!isActionEnabled("reactions")) { - throw new Error("Telegram reactions are disabled."); + throw new Error("Telegram reactions are disabled via actions.reactions."); } const chatId = readStringOrNumberParam(params, "chatId", { required: true, diff --git a/src/config/types.ts b/src/config/types.ts index bbc165b8a..3ded5f7e2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -423,8 +423,10 @@ export type TelegramAccountConfig = { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ streamMode?: "off" | "partial" | "block"; - /** Reaction notification mode: off, own (default), all. */ - reactionNotifications?: "off" | "own" | "all"; + /** Reaction notification mode: off (default), all. */ + reactionNotifications?: "off" | "all"; + /** Reaction level: off, ack (default), minimal, extensive. */ + reactionLevel?: "off" | "ack" | "minimal" | "extensive"; mediaMaxMb?: number; /** Retry policy for outbound Telegram API calls. */ retry?: OutboundRetryConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0e61fc460..0ac31345c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -297,7 +297,8 @@ const TelegramAccountSchemaBase = z.object({ draftChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), - reactionNotifications: z.enum(["off", "own", "all"]).optional(), + reactionNotifications: z.enum(["off", "all"]).optional(), + reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), mediaMaxMb: z.number().positive().optional(), retry: RetryConfigSchema, proxy: z.string().optional(), diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 0a8882fa2..3aa8af1ac 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2244,14 +2244,13 @@ describe("createTelegramBot", () => { expect(reactionHandler).toBeDefined(); }); - it("enqueues system event for reaction on bot message", async () => { + it("enqueues system event for reaction", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); - wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, + telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); @@ -2312,37 +2311,6 @@ describe("createTelegramBot", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); - it("skips reaction in own mode when message was not sent by bot", async () => { - onSpy.mockReset(); - enqueueSystemEvent.mockReset(); - wasSentByBot.mockReturnValue(false); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 502 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 99, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: "👍" }], - }, - }); - - expect(enqueueSystemEvent).not.toHaveBeenCalled(); - }); - it("allows reaction in all mode regardless of message sender", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); @@ -2381,11 +2349,10 @@ describe("createTelegramBot", () => { it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); - wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, + telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); @@ -2408,4 +2375,120 @@ describe("createTelegramBot", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); + + it("uses correct session key for forum group reactions with topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 505 }, + messageReaction: { + chat: { id: 5678, type: "supergroup", is_forum: true }, + message_id: 100, + message_thread_id: 42, + user: { id: 10, first_name: "Bob", username: "bob_user" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🔥" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 🔥 by Bob (@bob_user) on msg 100", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:5678:topic:42"), + contextKey: expect.stringContaining("telegram:reaction:add:5678:100:10"), + }), + ); + }); + + it("uses correct session key for forum group reactions in general topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 506 }, + messageReaction: { + chat: { id: 5678, type: "supergroup", is_forum: true }, + message_id: 101, + // No message_thread_id - should default to general topic (1) + user: { id: 10, first_name: "Bob" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👀" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 👀 by Bob on msg 101", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), + contextKey: expect.stringContaining("telegram:reaction:add:5678:101:10"), + }), + ); + }); + + it("uses correct session key for regular group reactions without topic", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 507 }, + messageReaction: { + chat: { id: 9999, type: "group" }, + message_id: 200, + user: { id: 11, first_name: "Charlie" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "❤️" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: ❤️ by Charlie on msg 200", + expect.objectContaining({ + sessionKey: expect.stringContaining("telegram:group:9999"), + contextKey: expect.stringContaining("telegram:reaction:add:9999:200:11"), + }), + ); + // Verify session key does NOT contain :topic: + const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey; + expect(sessionKey).not.toContain(":topic:"); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 21c2c8d41..2a02b7503 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -28,9 +28,14 @@ import { createDedupeCache } from "../infra/dedupe.js"; import { formatErrorMessage } from "../infra/errors.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; -import { resolveTelegramForumThreadId, resolveTelegramStreamMode } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + resolveTelegramForumThreadId, + resolveTelegramStreamMode, +} from "./bot/helpers.js"; import type { TelegramContext, TelegramMessage } from "./bot/types.js"; import { registerTelegramHandlers } from "./bot-handlers.js"; import { createTelegramMessageProcessor } from "./bot-message.js"; @@ -322,15 +327,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { const messageId = reaction.message_id; const user = reaction.user; - // Resolve reaction notification mode (default: "own") - const reactionMode = telegramCfg.reactionNotifications ?? "own"; + // Resolve reaction notification mode (default: "off") + const reactionMode = telegramCfg.reactionNotifications ?? "off"; if (reactionMode === "off") return; - // For "own" mode, only notify for reactions to bot's messages - if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) { - return; - } - // Detect added reactions const oldEmojis = new Set( reaction.old_reaction @@ -364,14 +364,25 @@ export function createTelegramBot(opts: TelegramBotOptions) { } senderLabel = senderLabel || "unknown"; + // Extract forum thread info (similar to message processing) + const messageThreadId = (reaction as any).message_thread_id; + const isForum = (reaction.chat as any).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + // Resolve agent route for session const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const peerId = isGroup + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) + : String(chatId); const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: account.accountId, - peer: { kind: isGroup ? "group" : "dm", id: String(chatId) }, + peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); // Enqueue system event for each added reaction diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index f84f52fc7..d0417669b 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -33,6 +33,11 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + + beforeAll(() => { + process.env.TELEGRAM_BOT_TOKEN = "test-token"; + }); + + afterAll(() => { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + }); + + it("defaults to ack level when reactionLevel is not set", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: {} }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("ack"); + expect(result.ackEnabled).toBe(true); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns off level with no reactions enabled", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "off" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("off"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns ack level with only ackEnabled", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "ack" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("ack"); + expect(result.ackEnabled).toBe(true); + expect(result.agentReactionsEnabled).toBe(false); + expect(result.agentReactionGuidance).toBeUndefined(); + }); + + it("returns minimal level with agent reactions enabled and minimal guidance", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "minimal" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("minimal"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("minimal"); + }); + + it("returns extensive level with agent reactions enabled and extensive guidance", () => { + const cfg: ClawdbotConfig = { + channels: { telegram: { reactionLevel: "extensive" } }, + }; + + const result = resolveTelegramReactionLevel({ cfg }); + expect(result.level).toBe("extensive"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("extensive"); + }); + + it("resolves reaction level from a specific account", () => { + const cfg: ClawdbotConfig = { + channels: { + telegram: { + reactionLevel: "ack", + accounts: { + work: { botToken: "tok-work", reactionLevel: "extensive" }, + }, + }, + }, + }; + + const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); + expect(result.level).toBe("extensive"); + expect(result.ackEnabled).toBe(false); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("extensive"); + }); + + it("falls back to global level when account has no reactionLevel", () => { + const cfg: ClawdbotConfig = { + channels: { + telegram: { + reactionLevel: "minimal", + accounts: { + work: { botToken: "tok-work" }, + }, + }, + }, + }; + + const result = resolveTelegramReactionLevel({ cfg, accountId: "work" }); + expect(result.level).toBe("minimal"); + expect(result.agentReactionsEnabled).toBe(true); + expect(result.agentReactionGuidance).toBe("minimal"); + }); +}); diff --git a/src/telegram/reaction-level.ts b/src/telegram/reaction-level.ts new file mode 100644 index 000000000..050e13990 --- /dev/null +++ b/src/telegram/reaction-level.ts @@ -0,0 +1,65 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveTelegramAccount } from "./accounts.js"; + +export type TelegramReactionLevel = "off" | "ack" | "minimal" | "extensive"; + +export type ResolvedReactionLevel = { + level: TelegramReactionLevel; + /** Whether ACK reactions (e.g., 👀 when processing) are enabled. */ + ackEnabled: boolean; + /** Whether agent-controlled reactions are enabled. */ + agentReactionsEnabled: boolean; + /** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */ + agentReactionGuidance?: "minimal" | "extensive"; +}; + +/** + * Resolve the effective reaction level and its implications. + */ +export function resolveTelegramReactionLevel(params: { + cfg: ClawdbotConfig; + accountId?: string; +}): ResolvedReactionLevel { + const account = resolveTelegramAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const level = (account.config.reactionLevel ?? + "ack") as TelegramReactionLevel; + + switch (level) { + case "off": + return { + level, + ackEnabled: false, + agentReactionsEnabled: false, + }; + case "ack": + return { + level, + ackEnabled: true, + agentReactionsEnabled: false, + }; + case "minimal": + return { + level, + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "minimal", + }; + case "extensive": + return { + level, + ackEnabled: false, + agentReactionsEnabled: true, + agentReactionGuidance: "extensive", + }; + default: + // Fallback to ack behavior + return { + level: "ack", + ackEnabled: true, + agentReactionsEnabled: false, + }; + } +} diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 6fa0342d5..363aecec0 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -63,6 +63,10 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, + allowed_updates: [ + "message", + "message_reaction", + ], }); await new Promise((resolve) => server.listen(port, host, resolve));