diff --git a/CHANGELOG.md b/CHANGELOG.md index 436b7972a..d3b3da992 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 7cd6c45ea..5862e908c 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -297,6 +297,49 @@ 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) + - `"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) (default) + - `"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 +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 | 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 3d5313bdc..1cd259205 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/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/auto-reply/commands-args.ts b/src/auto-reply/commands-args.ts index 63f73c468..7122d5457 100644 --- a/src/auto-reply/commands-args.ts +++ b/src/auto-reply/commands-args.ts @@ -4,14 +4,20 @@ export type CommandArgsFormatter = (values: CommandArgValues) => string | undefi function normalizeArgValue(value: unknown): string | undefined { if (value == null) return undefined; + let text: string; if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; + text = value.trim(); + } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + text = String(value).trim(); + } else if (typeof value === "symbol") { + text = value.toString().trim(); + } else if (typeof value === "function") { + text = value.toString().trim(); + } else { + // Objects and arrays + text = JSON.stringify(value); } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - return undefined; + return text ? text : undefined; } const formatConfigArgs: CommandArgsFormatter = (values) => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 5edc677d2..4da2f9869 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -137,13 +137,25 @@ function formatPositionalArgs( for (const definition of definitions) { const value = values[definition.name]; if (value == null) continue; + let rendered: string; if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) continue; - parts.push(trimmed); - } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - parts.push(String(value)); + rendered = value.trim(); + } else if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + rendered = String(value); + } else if (typeof value === "symbol") { + rendered = value.toString(); + } else if (typeof value === "function") { + rendered = value.toString(); + } else { + // Objects and arrays + rendered = JSON.stringify(value); } + if (!rendered) continue; + parts.push(rendered); if (definition.captureRemaining) break; } return parts.length > 0 ? parts.join(" ") : undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index e110e5a9b..647088460 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -84,6 +84,9 @@ function formatTemplateValue(value: unknown): string { if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } + if (typeof value === "symbol" || typeof value === "function") { + return value.toString(); + } if (Array.isArray(value)) { return value .flatMap((entry) => { @@ -96,6 +99,9 @@ function formatTemplateValue(value: unknown): string { }) .join(","); } + if (typeof value === "object") { + return JSON.stringify(value); + } return ""; } diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index a3c26d076..29ad277dd 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -76,6 +76,21 @@ export type TelegramAccountConfig = { webhookPath?: string; /** Per-action tool gating (default: true for all). */ actions?: TelegramActionConfig; + /** + * 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" | "own" | "all"; + /** + * Controls agent's reaction capability: + * - "off": agent cannot react + * - "ack" (default): bot sends acknowledgment reactions (👀 while processing) + * - "minimal": agent can react sparingly (guideline: 1 per 5-10 exchanges) + * - "extensive": agent can react liberally when appropriate + */ + reactionLevel?: "off" | "ack" | "minimal" | "extensive"; }; export type TelegramTopicConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3d18799b3..4bccca5f9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -63,8 +63,12 @@ export const TelegramAccountSchemaBase = z.object({ actions: z .object({ reactions: z.boolean().optional(), + sendMessage: z.boolean().optional(), + deleteMessage: z.boolean().optional(), }) .optional(), + reactionNotifications: z.enum(["off", "own", "all"]).optional(), + reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), }); export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { 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 new file mode 100644 index 000000000..a218adc4d --- /dev/null +++ b/src/telegram/bot.test.ts @@ -0,0 +1,2374 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import * as replyModule from "../auto-reply/reply.js"; +import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + +const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), +})); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +})); + +const { enqueueSystemEvent } = vi.hoisted(() => ({ + enqueueSystemEvent: vi.fn(), +})); +vi.mock("../infra/system-events.js", () => ({ + enqueueSystemEvent, +})); + +const { wasSentByBot } = vi.hoisted(() => ({ + wasSentByBot: vi.fn(() => false), +})); +vi.mock("./sent-message-cache.js", () => ({ + wasSentByBot, + recordSentMessage: vi.fn(), + clearSentMessageCache: vi.fn(), +})); + +const useSpy = vi.fn(); +const middlewareUseSpy = vi.fn(); +const onSpy = vi.fn(); +const stopSpy = vi.fn(); +const commandSpy = vi.fn(); +const botCtorSpy = vi.fn(); +const answerCallbackQuerySpy = vi.fn(async () => undefined); +const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); +const setMyCommandsSpy = 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 }; + answerCallbackQuery: typeof answerCallbackQuerySpy; + sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; + setMyCommands: typeof setMyCommandsSpy; + sendMessage: typeof sendMessageSpy; + sendAnimation: typeof sendAnimationSpy; + sendPhoto: typeof sendPhotoSpy; +}; +const apiStub: ApiStub = { + config: { use: useSpy }, + answerCallbackQuery: answerCallbackQuerySpy, + sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, + setMyCommands: setMyCommandsSpy, + sendMessage: sendMessageSpy, + sendAnimation: sendAnimationSpy, + sendPhoto: sendPhotoSpy, +}; + +vi.mock("grammy", () => ({ + Bot: class { + api = apiStub; + use = middlewareUseSpy; + on = onSpy; + stop = stopSpy; + command = commandSpy; + constructor( + public token: string, + public options?: { client?: { fetch?: typeof fetch } }, + ) { + botCtorSpy(token, options); + } + }, + InputFile: class {}, + webhookCallback: vi.fn(), +})); + +const sequentializeMiddleware = vi.fn(); +const sequentializeSpy = vi.fn(() => sequentializeMiddleware); +let sequentializeKey: ((ctx: unknown) => string) | undefined; +vi.mock("@grammyjs/runner", () => ({ + sequentialize: (keyFn: (ctx: unknown) => string) => { + sequentializeKey = keyFn; + return sequentializeSpy(); + }, +})); + +const throttlerSpy = vi.fn(() => "throttler"); + +vi.mock("@grammyjs/transformer-throttler", () => ({ + apiThrottler: () => throttlerSpy(), +})); + +vi.mock("../auto-reply/reply.js", () => { + const replySpy = vi.fn(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return undefined; + }); + return { getReplyFromConfig: replySpy, __replySpy: replySpy }; +}); + +const getOnHandler = (event: string) => { + const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; + if (!handler) throw new Error(`Missing handler for event: ${event}`); + return handler as (ctx: Record) => Promise; +}; + +describe("createTelegramBot", () => { + beforeEach(() => { + resetInboundDedupe(); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + loadWebMedia.mockReset(); + sendAnimationSpy.mockReset(); + sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); + answerCallbackQuerySpy.mockReset(); + setMyCommandsSpy.mockReset(); + wasSentByBot.mockReset(); + middlewareUseSpy.mockReset(); + sequentializeSpy.mockReset(); + botCtorSpy.mockReset(); + sequentializeKey = undefined; + }); + + it("installs grammY throttler", () => { + createTelegramBot({ token: "tok" }); + expect(throttlerSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith("throttler"); + }); + + it("forces native fetch only under Bun", () => { + const originalFetch = globalThis.fetch; + const originalBun = (globalThis as { Bun?: unknown }).Bun; + const fetchSpy = vi.fn() as unknown as typeof fetch; + globalThis.fetch = fetchSpy; + try { + (globalThis as { Bun?: unknown }).Bun = {}; + createTelegramBot({ token: "tok" }); + const fetchImpl = resolveTelegramFetch(); + expect(fetchImpl).toBe(fetchSpy); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchSpy }), + }), + ); + } finally { + globalThis.fetch = originalFetch; + if (originalBun === undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } else { + (globalThis as { Bun?: unknown }).Bun = originalBun; + } + } + }); + + it("does not force native fetch on Node", () => { + const originalFetch = globalThis.fetch; + const originalBun = (globalThis as { Bun?: unknown }).Bun; + const fetchSpy = vi.fn() as unknown as typeof fetch; + globalThis.fetch = fetchSpy; + try { + if (originalBun !== undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } + createTelegramBot({ token: "tok" }); + const fetchImpl = resolveTelegramFetch(); + expect(fetchImpl).toBeUndefined(); + expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined); + } finally { + globalThis.fetch = originalFetch; + if (originalBun === undefined) { + delete (globalThis as { Bun?: unknown }).Bun; + } else { + (globalThis as { Bun?: unknown }).Bun = originalBun; + } + } + }); + + it("sequentializes updates by chat and thread", () => { + createTelegramBot({ token: "tok" }); + expect(sequentializeSpy).toHaveBeenCalledTimes(1); + expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); + expect(sequentializeKey).toBe(getTelegramSequentialKey); + expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, message_thread_id: 9 }, + }), + ).toBe("telegram:123:topic:9"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, is_forum: true } }, + }), + ).toBe("telegram:123:topic:1"); + expect( + getTelegramSequentialKey({ + update: { message: { chat: { id: 555 } } }, + }), + ).toBe("telegram:555"); + }); + + it("routes callback_query payloads as messages and answers callbacks", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-1", + data: "cmd:option_a", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("cmd:option_a"); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); + }); + + it("wraps inbound message with Telegram envelope", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Vienna"; + + try { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, // 2025-01-09T00:00:00Z + from: { + first_name: "Ada", + last_name: "Lovelace", + username: "ada_bot", + }, + }; + await handler({ + message, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch( + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + ); + expect(payload.Body).toContain("hello world"); + } finally { + process.env.TZ = originalTz; + } + }); + + it("requests pairing by default for unknown DM senders", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest.mockResolvedValue({ + code: "PAIRME12", + created: true, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Your Telegram user id: 999"); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12"); + }); + + it("does not resend pairing code when a request is already pending", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest + .mockResolvedValueOnce({ code: "PAIRME12", created: true }) + .mockResolvedValueOnce({ code: "PAIRME12", created: false }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }; + + await handler({ + message, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await handler({ + message: { ...message, text: "hello again" }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + }); + + it("triggers typing cue via onReplyStart", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { chat: { id: 42, type: "private" }, text: "hi" }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); + }); + + it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(true); + expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/); + }); + + it("includes sender identity in group envelope headers", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toMatch( + /^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/, + ); + }); + + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + onSpy.mockReset(); + setMessageReactionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { mentionPatterns: ["\\bbert\\b"] }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") 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("clears native commands when disabled", () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + }); + + createTelegramBot({ token: "tok" }); + + expect(setMyCommandsSpy).toHaveBeenCalledWith([]); + }); + + it("skips group messages when requireMention is enabled and no mention matches", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 2, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: [] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: {}, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(false); + }); + + it("includes reply-to context when a Telegram reply is received", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Replying to Ada id:9001]"); + expect(payload.Body).toContain("Can you summarize this?"); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("Can you summarize this?"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("sends replies without native reply threading", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "a".repeat(4500) }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect(call[2]?.reply_to_message_id).toBeUndefined(); + } + }); + + it("honors replyToMode=first for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "first" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + const [first, ...rest] = sendMessageSpy.mock.calls; + expect(first?.[2]?.reply_to_message_id).toBe(101); + for (const call of rest) { + expect(call[2]?.reply_to_message_id).toBeUndefined(); + } + }); + + it("prefixes tool and final replies with responsePrefix", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool result" }); + return { text: "final reply" }; + }); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + messages: { responsePrefix: "PFX" }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(2); + expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result"); + expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply"); + }); + + it("honors replyToMode=all for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "all" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect(call[2]?.reply_to_message_id).toBe(101); + } + }); + + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("skips group messages without mention when requireMention is enabled", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { groups: { "*": { requireMention: true } } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("honors routed group activation from session store", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-telegram-")); + const storePath = path.join(storeDir, "sessions.json"); + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:ops:telegram:group:123": { groupActivation: "always" }, + }), + "utf-8", + ); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + bindings: [ + { + agentId: "ops", + match: { + channel: "telegram", + peer: { kind: "group", id: "123" }, + }, + }, + ], + session: { store: storePath }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Routing" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("routes DMs by telegram accountId binding", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + accounts: { + opie: { + botToken: "tok-opie", + dmPolicy: "open", + }, + }, + }, + }, + bindings: [ + { + agentId: "opie", + match: { channel: "telegram", accountId: "opie" }, + }, + ], + }); + + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toBe("agent:opie:main"); + }); + + it("allows per-group requireMention override", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows per-topic requireMention override", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "-1001234567890": { + requireMention: true, + topics: { + "99": { requireMention: false }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello", + date: 1736380800, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("honors groups default when no explicit group override exists", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("does not block group messages when bot username is unknown", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 789, type: "group", title: "No Me" }, + text: "hello", + date: 1736380800, + }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("sends GIF replies as animations", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + replySpy.mockResolvedValueOnce({ + text: "caption", + mediaUrl: "https://example.com/fun", + }); + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + reply_to_message_id: undefined, + }); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + }); + + // groupPolicy tests + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should NOT call getReplyFromConfig because groupPolicy is disabled + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], // Does not include sender 999999 + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "notallowed" }, // Not in allowFrom + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, // Skip mention check + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // In allowFrom + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], // By username + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Username matches @testuser + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows all group messages when groupPolicy is 'open'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], // Uppercase in config + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Lowercase in message + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", // Even with disabled, DMs should work + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: [" TG:123456789 "], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: ["telegram:123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], // Wildcard allows everyone + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender, but wildcard allows + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + // No `from` field (e.g., channel post or anonymous admin) + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], // Prefixed format + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping telegram: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping tg: prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping tg: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: [" TG:123456789 "], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "/status", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("isolates forum topic sessions and carries thread metadata", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); + expect(payload.From).toBe("group:-1001234567890:topic:99"); + expect(payload.MessageThreadId).toBe(99); + expect(payload.IsForum).toBe(true); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 99, + }); + }); + + it("falls back to General topic thread id for typing in forums", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 1, + }); + }); + + it("routes General topic replies using thread id 1", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 1 }), + ); + }); + + it("applies topic skill filters and system prompts", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-1001234567890": { + requireMention: false, + systemPrompt: "Group prompt", + skills: ["group-skill"], + topics: { + "99": { + skills: [], + systemPrompt: "Topic prompt", + }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); + const opts = replySpy.mock.calls[0][1]; + expect(opts?.skillFilter).toEqual([]); + }); + + it("passes message_thread_id to topic replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + + it("threads native command replies inside topics", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + expect(commandSpy).toHaveBeenCalled(); + const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "/status", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + match: "", + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + + it("streams tool summaries for native slash commands", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool update" }); + return { text: "final reply" }; + }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }); + + createTelegramBot({ token: "tok" }); + const verboseHandler = commandSpy.mock.calls.find((call) => call[0] === "verbose")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!verboseHandler) throw new Error("verbose command handler missing"); + + await verboseHandler({ + message: { + chat: { id: 12345, type: "private" }, + from: { id: 12345, username: "testuser" }, + text: "/verbose on", + date: 1736380800, + message_id: 42, + }, + match: "on", + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(2); + expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update"); + expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply"); + }); + + it("dedupes duplicate message updates by update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const ctx = { + update: { update_id: 111 }, + message: { + chat: { id: 123, type: "private" }, + from: { id: 456, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("dedupes duplicate callback_query updates by update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + const ctx = { + update: { update_id: 222 }, + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows distinct callback_query ids without update_id", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await handler({ + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }); + + await handler({ + callbackQuery: { + id: "cb-2", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({}), + }); + + expect(replySpy).toHaveBeenCalledTimes(2); + }); + + it("registers message_reaction handler", () => { + onSpy.mockReset(); + createTelegramBot({ token: "tok" }); + const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction"); + expect(reactionHandler).toBeDefined(); + }); + + it("enqueues system event for reaction", 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: 500 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada", username: "ada_bot" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 👍 by Ada (@ada_bot) on msg 42", + expect.objectContaining({ + contextKey: expect.stringContaining("telegram:reaction:add:1234:42:9"), + }), + ); + }); + + it("skips reaction when reactionNotifications is off", async () => { + onSpy.mockReset(); + enqueueSystemEvent.mockReset(); + wasSentByBot.mockReturnValue(true); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", reactionNotifications: "off" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 501 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + 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(); + wasSentByBot.mockReturnValue(false); + + 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: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🎉" }], + }, + }); + + expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Telegram reaction added: 🎉 by Ada on msg 99", + expect.any(Object), + ); + }); + + 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(); + + 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: 504 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [{ type: "emoji", emoji: "👍" }], + new_reaction: [], + }, + }); + + 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 31a345fcb..31030516c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -18,11 +18,17 @@ import { resolveChannelGroupRequireMention, } from "../config/group-policy.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { danger, logVerbose, shouldLogVerbose } from "../globals.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"; @@ -34,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; @@ -59,8 +66,14 @@ export function getTelegramSequentialKey(ctx: { message?: TelegramMessage; edited_message?: TelegramMessage; callback_query?: { message?: TelegramMessage }; + message_reaction?: { chat?: { id?: number } }; }; }): string { + // Handle reaction updates + const reaction = ctx.update?.message_reaction; + if (reaction?.chat?.id) { + return `telegram:${reaction.chat.id}`; + } const msg = ctx.message ?? ctx.update?.message ?? @@ -291,6 +304,84 @@ export function createTelegramBot(opts: TelegramBotOptions) { opts, }); + // Handle emoji reactions to messages + bot.on("message_reaction", async (ctx) => { + try { + const reaction = ctx.messageReaction; + if (!reaction) return; + if (shouldSkipUpdate(ctx)) return; + + const chatId = reaction.chat.id; + const messageId = reaction.message_id; + const user = reaction.user; + + // 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( + reaction.old_reaction + .filter((r): r is { type: "emoji"; emoji: string } => r.type === "emoji") + .map((r) => r.emoji), + ); + const addedReactions = reaction.new_reaction + .filter((r): r is { type: "emoji"; emoji: string } => r.type === "emoji") + .filter((r) => !oldEmojis.has(r.emoji)); + + if (addedReactions.length === 0) return; + + // Build sender label + const senderName = user + ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username + : undefined; + const senderUsername = user?.username ? `@${user.username}` : undefined; + let senderLabel = senderName; + if (senderName && senderUsername) { + senderLabel = `${senderName} (${senderUsername})`; + } else if (!senderName && senderUsername) { + senderLabel = senderUsername; + } + if (!senderLabel && user?.id) { + senderLabel = `id:${user.id}`; + } + 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: peerId }, + }); + + // Enqueue system event for each added reaction + for (const r of addedReactions) { + const emoji = r.emoji; + const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`, + }); + logVerbose(`telegram: reaction event enqueued: ${text}`); + } + } catch (err) { + runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); + } + }); + registerTelegramHandlers({ cfg, accountId: account.accountId, diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index f84f52fc7..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,6 +34,8 @@ 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..d0e56020a --- /dev/null +++ b/src/telegram/reaction-level.ts @@ -0,0 +1,64 @@ +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/send.ts b/src/telegram/send.ts index 0d6c3fca5..d2c54ff49 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; @@ -272,6 +273,9 @@ export async function sendMessageTelegram( } const mediaMessageId = String(result?.message_id ?? "unknown"); const resolvedChatId = String(result?.chat?.id ?? chatId); + if (result?.message_id) { + recordSentMessage(chatId, result.message_id); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, @@ -353,6 +357,9 @@ export async function sendMessageTelegram( }, ); const messageId = String(res?.message_id ?? "unknown"); + if (res?.message_id) { + recordSentMessage(chatId, res.message_id); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, diff --git a/src/telegram/sent-message-cache.test.ts b/src/telegram/sent-message-cache.test.ts new file mode 100644 index 000000000..054d9b2fc --- /dev/null +++ b/src/telegram/sent-message-cache.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-message-cache.js"; + +describe("sent-message-cache", () => { + afterEach(() => { + clearSentMessageCache(); + }); + + it("records and retrieves sent messages", () => { + recordSentMessage(123, 1); + recordSentMessage(123, 2); + recordSentMessage(456, 10); + + expect(wasSentByBot(123, 1)).toBe(true); + expect(wasSentByBot(123, 2)).toBe(true); + expect(wasSentByBot(456, 10)).toBe(true); + expect(wasSentByBot(123, 3)).toBe(false); + expect(wasSentByBot(789, 1)).toBe(false); + }); + + it("handles string chat IDs", () => { + recordSentMessage("123", 1); + expect(wasSentByBot("123", 1)).toBe(true); + expect(wasSentByBot(123, 1)).toBe(true); + }); + + it("clears cache", () => { + recordSentMessage(123, 1); + expect(wasSentByBot(123, 1)).toBe(true); + + clearSentMessageCache(); + expect(wasSentByBot(123, 1)).toBe(false); + }); +}); diff --git a/src/telegram/sent-message-cache.ts b/src/telegram/sent-message-cache.ts new file mode 100644 index 000000000..05c12ddf7 --- /dev/null +++ b/src/telegram/sent-message-cache.ts @@ -0,0 +1,64 @@ +/** + * In-memory cache of sent message IDs per chat. + * Used to identify bot's own messages for reaction filtering ("own" mode). + */ + +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +type CacheEntry = { + messageIds: Set; + timestamps: Map; +}; + +const sentMessages = new Map(); + +function getChatKey(chatId: number | string): string { + return String(chatId); +} + +function cleanupExpired(entry: CacheEntry): void { + const now = Date.now(); + for (const [msgId, timestamp] of entry.timestamps) { + if (now - timestamp > TTL_MS) { + entry.messageIds.delete(msgId); + entry.timestamps.delete(msgId); + } + } +} + +/** + * Record a message ID as sent by the bot. + */ +export function recordSentMessage(chatId: number | string, messageId: number): void { + const key = getChatKey(chatId); + let entry = sentMessages.get(key); + if (!entry) { + entry = { messageIds: new Set(), timestamps: new Map() }; + sentMessages.set(key, entry); + } + entry.messageIds.add(messageId); + entry.timestamps.set(messageId, Date.now()); + // Periodic cleanup + if (entry.messageIds.size > 100) { + cleanupExpired(entry); + } +} + +/** + * Check if a message was sent by the bot. + */ +export function wasSentByBot(chatId: number | string, messageId: number): boolean { + const key = getChatKey(chatId); + const entry = sentMessages.get(key); + if (!entry) return false; + // Clean up expired entries on read + cleanupExpired(entry); + return entry.messageIds.has(messageId); +} + +/** + * Clear all cached entries (for testing). + */ +export function clearSentMessageCache(): void { + sentMessages.clear(); +} diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts index e89b50043..04bccfe07 100644 --- a/src/telegram/webhook.test.ts +++ b/src/telegram/webhook.test.ts @@ -16,9 +16,10 @@ const createTelegramBotSpy = vi.fn(() => ({ 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 6fa0342d5..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,6 +64,7 @@ export async function startTelegramWebhook(opts: { await bot.api.setWebhook(publicUrl, { secret_token: opts.secret, + allowed_updates: resolveTelegramAllowedUpdates(), }); await new Promise((resolve) => server.listen(port, host, resolve));