From 2b1c26f900d343be9f4c6d52ea5578a3d608e2af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 17:20:17 +0000 Subject: [PATCH] fix: refine telegram reactions (#964) (thanks @bohdanpodvirnyi) --- CHANGELOG.md | 1 + docs/channels/telegram.md | 7 +- src/agents/pi-embedded-runner/run/attempt.ts | 13 +++ .../pi-embedded-runner/system-prompt.ts | 5 + src/agents/system-prompt.test.ts | 13 +++ src/config/types.telegram.ts | 3 +- src/config/zod-schema.providers-core.ts | 2 +- src/telegram/allowed-updates.ts | 11 +++ src/telegram/bot.test.ts | 94 +++++++++++++++++++ src/telegram/bot.ts | 3 + src/telegram/monitor.ts | 23 +---- src/telegram/webhook.test.ts | 7 +- src/telegram/webhook.ts | 3 +- 13 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 src/telegram/allowed-updates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0452e772b..592a70847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026.1.15 (unreleased) - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. +- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. ## 2026.1.14-1 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 74e4a24f0..5862e908c 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -312,11 +312,12 @@ The agent sees reactions as **system notifications** in the conversation history **Configuration:** - `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications - `"off"` — ignore all reactions (default when not set) + - `"own"` — notify when users react to bot messages (best-effort; in-memory) - `"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) + - `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default) - `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges) - `"extensive"` — agent can react liberally when appropriate @@ -402,8 +403,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). +- `channels.telegram.reactionNotifications`: `off | own | 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: `ack` when not set). Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 93e881c59..0662cf56d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; +import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { resolveUserPath } from "../../../utils.js"; @@ -161,6 +162,17 @@ export async function runEmbeddedAttempt( accountId: params.agentAccountId, }) ?? []) : undefined; + const reactionGuidance = + runtimeChannel === "telegram" && params.config + ? (() => { + const resolved = resolveTelegramReactionLevel({ + cfg: params.config, + accountId: params.agentAccountId ?? undefined, + }); + const level = resolved.agentReactionGuidance; + return level ? { level, channel: "Telegram" } : undefined; + })() + : undefined; const runtimeInfo = { host: machineName, os: `${os.type()} ${os.release()}`, @@ -192,6 +204,7 @@ export async function runEmbeddedAttempt( ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined, skillsPrompt, + reactionGuidance, runtimeInfo, sandboxInfo, tools, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 4aa4867e3..0c07006ce 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -14,6 +14,10 @@ export function buildEmbeddedSystemPrompt(params: { reasoningTagHint: boolean; heartbeatPrompt?: string; skillsPrompt?: string; + reactionGuidance?: { + level: "minimal" | "extensive"; + channel: string; + }; runtimeInfo: { host: string; os: string; @@ -40,6 +44,7 @@ export function buildEmbeddedSystemPrompt(params: { reasoningTagHint: params.reasoningTagHint, heartbeatPrompt: params.heartbeatPrompt, skillsPrompt: params.skillsPrompt, + reactionGuidance: params.reactionGuidance, runtimeInfo: params.runtimeInfo, sandboxInfo: params.sandboxInfo, toolNames: params.tools.map((tool) => tool.name), diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 260c7c631..7a43b17dc 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -208,4 +208,17 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("User can toggle with /elevated on|off."); expect(prompt).toContain("Current elevated level: on"); }); + + it("includes reaction guidance when provided", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + reactionGuidance: { + level: "minimal", + channel: "Telegram", + }, + }); + + expect(prompt).toContain("## Reactions"); + expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode."); + }); }); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index d140ce8d6..29ad277dd 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -79,9 +79,10 @@ export type TelegramAccountConfig = { /** * Controls which user reactions trigger notifications: * - "off" (default): ignore all reactions + * - "own": notify when users react to bot messages * - "all": notify agent of all reactions */ - reactionNotifications?: "off" | "all"; + reactionNotifications?: "off" | "own" | "all"; /** * Controls agent's reaction capability: * - "off": agent cannot react diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 46be90167..4bccca5f9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -67,7 +67,7 @@ export const TelegramAccountSchemaBase = z.object({ deleteMessage: z.boolean().optional(), }) .optional(), - reactionNotifications: z.enum(["off", "all"]).optional(), + reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), }); diff --git a/src/telegram/allowed-updates.ts b/src/telegram/allowed-updates.ts new file mode 100644 index 000000000..e32fefd09 --- /dev/null +++ b/src/telegram/allowed-updates.ts @@ -0,0 +1,11 @@ +import { API_CONSTANTS } from "grammy"; + +type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number]; + +export function resolveTelegramAllowedUpdates(): ReadonlyArray { + const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[]; + if (!updates.includes("message_reaction")) { + updates.push("message_reaction"); + } + return updates; +} diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 8854908e7..a218adc4d 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -151,6 +151,7 @@ describe("createTelegramBot", () => { setMessageReactionSpy.mockReset(); answerCallbackQuerySpy.mockReset(); setMyCommandsSpy.mockReset(); + wasSentByBot.mockReset(); middlewareUseSpy.mockReset(); sequentializeSpy.mockReset(); botCtorSpy.mockReset(); @@ -2132,6 +2133,99 @@ describe("createTelegramBot", () => { ); }); + it("skips reaction in own mode when message is 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: 503 }, + 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 own mode when message is sent by bot", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + 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: 503 }, + 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).toHaveBeenCalledTimes(1); + }); + + it("skips reaction from bot users", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + 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: 503 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 99, + user: { id: 9, first_name: "Bot", is_bot: true }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🎉" }], + }, + }); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); enqueueSystemEvent.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ffd50f7f9..31030516c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -40,6 +40,7 @@ import { type TelegramUpdateKeyContext, } from "./bot-updates.js"; import { resolveTelegramFetch } from "./fetch.js"; +import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotOptions = { token: string; @@ -317,6 +318,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { // Resolve reaction notification mode (default: "off") const reactionMode = telegramCfg.reactionNotifications ?? "off"; if (reactionMode === "off") return; + if (user?.is_bot) return; + if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) return; // Detect added reactions const oldEmojis = new Set( diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index a842d77c3..bb8d6adb0 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -5,6 +5,7 @@ import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatDurationMs } from "../infra/format-duration.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; import { makeProxyFetch } from "./proxy.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; @@ -33,8 +34,8 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions ({ stop: stopSpy, })); -vi.mock("grammy", () => ({ - webhookCallback: () => handlerSpy, -})); +vi.mock("grammy", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, webhookCallback: () => handlerSpy }; +}); vi.mock("./bot.js", () => ({ createTelegramBot: (...args: unknown[]) => createTelegramBotSpy(...args), diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index a6f31ec94..6e2310b8b 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -5,6 +5,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; export async function startTelegramWebhook(opts: { @@ -63,7 +64,7 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, - allowed_updates: ["message", "message_reaction"], + allowed_updates: resolveTelegramAllowedUpdates(), }); await new Promise((resolve) => server.listen(port, host, resolve));