import { rm, writeFile } from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, } from "openclaw/plugin-sdk/plugin-runtime"; import { createPluginStateKeyedStoreForTests, createPluginStateSyncKeyedStoreForTests, resetPluginStateStoreForTests, } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, } from "./conversation-route.js"; import type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, } from "./interactive-dispatch.js"; import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js"; import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js"; import type { TelegramRuntime } from "./runtime.types.js"; const { answerCallbackQuerySpy, commandSpy, deleteMessageSpy, editMessageReplyMarkupSpy, editMessageTextSpy, enqueueSystemEventSpy, getFileSpy, getChatSpy, getLoadConfigMock, getLoadWebMediaMock, getReadChannelAllowFromStoreMock, getOnHandler, listSkillCommandsForAgents, onSpy, replySpy, resolveExecApprovalSpy, sendMessageSpy, setMyCommandsSpy, telegramBotDepsForTest, telegramBotRuntimeForTest, wasSentByBot, } = await import("./bot.create-telegram-bot.test-harness.js"); const { recordOutboundMessageForPromptContext } = await import("./outbound-message-context.js"); let createTelegramBotBase: typeof import("./bot-core.js").createTelegramBotCore; let setTelegramBotRuntimeForTest: typeof import("./bot-core.js").setTelegramBotRuntimeForTest; let createTelegramBot: ( opts: import("./bot.types.js").TelegramBotOptions, ) => ReturnType; const loadConfig = getLoadConfigMock(); const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); const PUZZLE_EMOJI = "\u{1F9E9}"; const INFO_EMOJI = "\u{2139}\u{FE0F}"; const CHECK_MARK_EMOJI = "\u{2705}"; const THUMBS_UP_EMOJI = "\u{1F44D}"; const FIRE_EMOJI = "\u{1F525}"; const PARTY_EMOJI = "\u{1F389}"; const EYES_EMOJI = "\u{1F440}"; const HEART_EMOJI = "\u{2764}\u{FE0F}"; function createSignal() { let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); if (!resolve) { throw new Error("Expected Telegram bot signal resolver to be initialized"); } return { promise, resolve }; } function waitForReplyCalls(count: number) { const done = createSignal(); let seen = 0; replySpy.mockImplementation(async (_ctx, opts) => { await opts?.onReplyStart?.(); seen += 1; if (seen >= count) { done.resolve(); } return undefined; }); return done.promise; } function setTelegramPluginStateRuntimeForTests() { setTelegramRuntime({ state: { openKeyedStore: ((options) => createPluginStateKeyedStoreForTests( "telegram", options, )) as TelegramRuntime["state"]["openKeyedStore"], openSyncKeyedStore: ((options) => createPluginStateSyncKeyedStoreForTests( "telegram", options, )) as TelegramRuntime["state"]["openSyncKeyedStore"], }, channel: {}, } as TelegramRuntime); } function getTelegramCallbackHandlerForTests() { return getOnHandler("callback_query") as (ctx: Record) => Promise; } async function loadEnvelopeTimestampHelpers() { return await import("openclaw/plugin-sdk/channel-test-helpers"); } async function loadInboundContextContract() { return await import("./test-support/inbound-context-contract.js"); } type MockCallSource = { mock: { calls: ReadonlyArray>; }; }; function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object") { throw new Error(`expected ${label}`); } return value as Record; } function requireArray(value: unknown, label: string): unknown[] { expect(Array.isArray(value), label).toBe(true); return value as unknown[]; } function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) { const call = source.mock.calls[callIndex]; if (!call) { throw new Error(`expected mock call: ${label}`); } return call[argIndex]; } function mockCall(source: MockCallSource, callIndex: number, label: string) { const call = source.mock.calls[callIndex]; if (!call) { throw new Error(`expected mock call: ${label}`); } return call; } function firstEditMessageTextArg(argIndex: number) { return mockArg(editMessageTextSpy as unknown as MockCallSource, 0, argIndex, "edit message text"); } function firstSystemEventArg(argIndex: number) { return mockArg(enqueueSystemEventSpy as unknown as MockCallSource, 0, argIndex, "system event"); } function mockMsgContextArg( source: MockCallSource, callIndex: number, argIndex: number, label: string, ): MsgContext { return mockArg(source, callIndex, argIndex, label) as MsgContext; } async function writeDirectTelegramTranscriptContext(params: { cfg: OpenClawConfig; storePath: string; chatId: number; role?: "assistant" | "user"; senderId: number; sessionId: string; text: string; timestamp: number; }) { const route = resolveTelegramConversationRoute({ cfg: params.cfg, accountId: "default", chatId: params.chatId, isGroup: false, senderId: params.senderId, }).route; const sessionKey = resolveTelegramConversationBaseSessionKey({ cfg: params.cfg, route, chatId: params.chatId, isGroup: false, senderId: params.senderId, }); await writeFile( params.storePath, JSON.stringify({ [sessionKey]: { sessionId: params.sessionId, chatType: "direct", channel: "telegram", }, }), "utf-8", ); await writeFile( path.join(path.dirname(params.storePath), `${params.sessionId}.jsonl`), [ JSON.stringify({ type: "session", id: params.sessionId }), JSON.stringify({ id: params.role === "assistant" ? "transcript-assistant-1" : "transcript-user-1", type: "message", message: { role: params.role ?? "user", content: params.text, timestamp: params.timestamp, }, }), "", ].join("\n"), "utf-8", ); } function execApprovalCall(index = 0) { return requireRecord( mockArg(resolveExecApprovalSpy as unknown as MockCallSource, index, 0, "exec approval call"), "exec approval call", ); } function execApprovalTelegramConfig(call = execApprovalCall()) { return requireRecord( requireRecord(requireRecord(call.cfg, "approval cfg").channels, "approval channels").telegram, "telegram config", ); } function execApprovalTargetConfig(call = execApprovalCall()) { return requireRecord( requireRecord(requireRecord(call.cfg, "approval cfg").approvals, "approvals").exec, "exec approvals target config", ); } function systemEventOptions(index = 0) { return requireRecord( mockArg(enqueueSystemEventSpy as unknown as MockCallSource, index, 1, "system event options"), "system event options", ); } const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { beforeAll(async () => { ({ createTelegramBotCore: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot-core.js")); }); beforeAll(() => { process.env.TZ = "UTC"; }); afterAll(() => { if (ORIGINAL_TZ === undefined) { delete process.env.TZ; } else { process.env.TZ = ORIGINAL_TZ; } }); beforeEach(() => { setMyCommandsSpy.mockClear(); clearPluginInteractiveHandlers(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); setTelegramBotRuntimeForTest( telegramBotRuntimeForTest as unknown as Parameters[0], ); createTelegramBot = (opts) => createTelegramBotBase({ ...opts, telegramDeps: telegramBotDepsForTest, }); }); it("starts with retired includeGroupHistoryContext still present in raw config", async () => { loadConfig.mockReturnValue({ messages: { groupChat: { unmentionedInbound: "room_event" } }, channels: { telegram: { includeGroupHistoryContext: "mention-only", }, }, } as never); createTelegramBot({ token: "tok" }); expect(getOnHandler("message")).toEqual(expect.any(Function)); }); it("records outbound prompt-context sends into ambient group history", async () => { onSpy.mockClear(); replySpy.mockClear(); const cfg = { messages: { groupChat: { unmentionedInbound: "room_event", mentionPatterns: [] } }, channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, } satisfies OpenClawConfig; loadConfig.mockReturnValue(cfg); createTelegramBot({ token: "tok", botInfo: { id: 999, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot", can_join_groups: true, can_read_all_group_messages: false, can_manage_bots: false, supports_inline_queries: false, supports_join_request_queries: false, can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false, allows_users_to_create_topics: false, }, }); await recordOutboundMessageForPromptContext({ cfg, account: { accountId: "default", name: "OpenClaw" }, chatId: -42, message: { chat: { id: -42, type: "group", title: "Ops" }, date: 1_736_380_700, message_id: 700, text: "Bot just replied", }, messageId: 700, text: "Bot just replied", }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), message: { chat: { id: -42, type: "group", title: "Ops" }, text: "What now?", date: 1_736_380_800, message_id: 701, from: { id: 201, is_bot: false, first_name: "Sam" }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.InboundEventKind).toBe("room_event"); expect(payload.InboundHistory).toEqual( expect.arrayContaining([ expect.objectContaining({ body: "Bot just replied", sender: "OpenClaw (you)" }), ]), ); }); it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => { onSpy.mockClear(); replySpy.mockClear(); sendMessageSpy.mockClear(); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "pairing", capabilities: { inlineButtons: "allowlist" }, allowFrom: [], }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-2", data: "cmd:option_b", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 11, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2"); }); it("blocks DM model-selection callbacks for unpaired users when inline buttons are DM-scoped", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-callback-authz-${process.pid}-${Date.now()}.json`; await rm(storePath, { force: true }); try { const config = { agents: { defaults: { model: "anthropic/claude-opus-4-6", models: { "anthropic/claude-opus-4-6": {}, "openai/gpt-5.4": {}, }, }, }, channels: { telegram: { dmPolicy: "pairing", capabilities: { inlineButtons: "dm" }, }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); readChannelAllowFromStore.mockResolvedValueOnce([]); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-authz-bypass-1", data: "mdl_sel_openai/gpt-5.4", from: { id: 999, first_name: "Mallory", username: "mallory" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 19, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({}); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-authz-bypass-1"); } finally { await rm(storePath, { force: true }); } }); it("blocks group model-selection callbacks for senders who are not authorized for /models", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-group-model-authz-${process.pid}-${Date.now()}.json`; await rm(storePath, { force: true }); try { const config = { agents: { defaults: { model: "anthropic/claude-opus-4-6", models: { "anthropic/claude-opus-4-6": {}, "openai/gpt-5.4": {}, }, }, }, commands: { allowFrom: { telegram: ["9"], }, }, channels: { telegram: { dmPolicy: "open", capabilities: { inlineButtons: "group" }, groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-group-model-authz-1", data: "mdl_sel_openai/gpt-5.4", from: { id: 999, first_name: "Mallory", username: "mallory" }, message: { chat: { id: -100999, type: "supergroup", title: "Test Group" }, date: 1736380800, message_id: 21, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({}); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-1"); } finally { await rm(storePath, { force: true }); } }); it("recomputes group model-selection callback auth from runtime command config", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-group-model-authz-runtime-${process.pid}-${Date.now()}.json`; await rm(storePath, { force: true }); try { let currentConfig = { agents: { defaults: { model: "anthropic/claude-opus-4-6", models: { "anthropic/claude-opus-4-6": {}, "openai/gpt-5.4": {}, }, }, }, commands: { allowFrom: { telegram: ["999"], }, }, channels: { telegram: { dmPolicy: "open", capabilities: { inlineButtons: "group" }, groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockImplementation(() => currentConfig); createTelegramBot({ token: "tok", config: currentConfig, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } currentConfig = { ...currentConfig, commands: { allowFrom: { telegram: ["9"], }, }, }; await callbackHandler({ callbackQuery: { id: "cbq-group-model-authz-runtime-1", data: "mdl_sel_openai/gpt-5.4", from: { id: 999, first_name: "Mallory", username: "mallory" }, message: { chat: { id: -100999, type: "supergroup", title: "Test Group" }, date: 1736380800, message_id: 22, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(loadSessionStore(storePath, { skipCache: true })).toStrictEqual({}); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-model-authz-runtime-1"); } finally { loadConfig.mockReset(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); await rm(storePath, { force: true }); } }); it("allows callback_query in groups when group policy authorizes the sender", async () => { onSpy.mockClear(); editMessageTextSpy.mockClear(); listSkillCommandsForAgents.mockClear(); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", capabilities: { inlineButtons: "allowlist" }, allowFrom: [], groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-group-1", data: "commands_page_2", from: { id: 42, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: -100999, type: "supergroup", title: "Test Group" }, date: 1736380800, message_id: 20, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); // The callback should be processed (not silently blocked) expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1"); }); it("clears approval buttons without re-editing callback message text", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-approve-style", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 21, text: [ `${PUZZLE_EMOJI} Yep-needs approval again.`, "", "Run:", "/approve 138e9b8c allow-once", "", "Pending command:", "```shell", "npm view diver name version description", "```", ].join("\n"), }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); const [chatId, messageId, replyMarkup] = mockCall( editMessageReplyMarkupSpy as unknown as MockCallSource, 0, "edit reply markup", ); expect(chatId).toBe(1234); expect(messageId).toBe(21); expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } }); const approvalCall = execApprovalCall(); const execApprovals = requireRecord( execApprovalTelegramConfig(approvalCall).execApprovals, "telegram exec approvals", ); expect(execApprovals.enabled).toBe(true); expect(execApprovals.approvers).toEqual(["9"]); expect(execApprovals.target).toBe("dm"); expect(approvalCall.approvalId).toBe("138e9b8c"); expect(approvalCall.decision).toBe("allow-once"); expect(approvalCall.allowPluginFallback).toBe(true); expect(approvalCall.senderId).toBe("9"); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style"); }); it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", dmPolicy: "open", allowFrom: ["*"], capabilities: ["vision"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-approve-capability-free", data: "tgcmd:/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 23, text: "Approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free"); }); it("resolves opaque plugin approval callbacks through the shared approval resolver", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-plugin-approve", data: "/approve plugin:138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 24, text: "Plugin approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const approvalCall = execApprovalCall(); const execApprovals = requireRecord( execApprovalTelegramConfig(approvalCall).execApprovals, "telegram exec approvals", ); expect(execApprovals.enabled).toBe(true); expect(execApprovals.approvers).toEqual(["9"]); expect(execApprovals.target).toBe("dm"); expect(approvalCall.approvalId).toBe("plugin:138e9b8c"); expect(approvalCall.decision).toBe("allow-once"); expect(approvalCall.allowPluginFallback).toBe(true); expect(approvalCall.senderId).toBe("9"); expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve"); }); it("does not resolve opaque approval-shaped plugin callbacks", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); resolveExecApprovalSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-opaque-plugin-approve", data: buildTelegramOpaqueCallbackData("/approve plugin:138e9b8c allow-once"), from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 25, text: "Plugin callback.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(resolveExecApprovalSpy).not.toHaveBeenCalled(); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-opaque-plugin-approve"); }); it("blocks approval callbacks from telegram users who are not exec approvers", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["999"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-approve-blocked", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 22, text: "Run: /approve 138e9b8c allow-once", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(resolveExecApprovalSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked"); }); it("keeps approval callback resolution failures out of Telegram chat before retry", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); resolveExecApprovalSpy.mockClear(); resolveExecApprovalSpy.mockRejectedValueOnce(new Error("gateway secret detail")); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await expect( callbackHandler({ callbackQuery: { id: "cbq-approve-error", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 25, text: "Approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }), ).rejects.toThrow("gateway secret detail"); expect(sendMessageSpy).not.toHaveBeenCalled(); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-error"); }); it("allows exec approval callbacks from target-only Telegram recipients", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); loadConfig.mockReturnValue({ approvals: { exec: { enabled: true, mode: "targets", targets: [{ channel: "telegram", to: "9" }], }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-approve-target", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 23, text: "Approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const approvalCall = execApprovalCall(); const execApprovals = execApprovalTargetConfig(approvalCall); expect(execApprovals.enabled).toBe(true); expect(execApprovals.mode).toBe("targets"); expect(approvalCall.approvalId).toBe("138e9b8c"); expect(approvalCall.decision).toBe("allow-once"); expect(approvalCall.allowPluginFallback).toBe(false); expect(approvalCall.senderId).toBe("9"); expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target"); }); it("drops target-only approval not-found misses without clearing legacy fallback buttons", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); replySpy.mockClear(); sendMessageSpy.mockClear(); resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id")); loadConfig.mockReturnValue({ approvals: { exec: { enabled: true, mode: "targets", targets: [{ channel: "telegram", to: "9" }], }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-legacy-plugin-fallback-blocked", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 25, text: "Legacy plugin approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const approvalCall = execApprovalCall(); const execApprovals = execApprovalTargetConfig(approvalCall); expect(execApprovals.enabled).toBe(true); expect(execApprovals.mode).toBe("targets"); expect(approvalCall.approvalId).toBe("138e9b8c"); expect(approvalCall.decision).toBe("allow-once"); expect(approvalCall.allowPluginFallback).toBe(false); expect(approvalCall.senderId).toBe("9"); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled(); expect(sendMessageSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked"); }); it("drops expired approval callbacks for configured approvers after clearing buttons", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); resolveExecApprovalSpy.mockClear(); replySpy.mockClear(); sendMessageSpy.mockClear(); resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id")); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], execApprovals: { enabled: true, approvers: ["9"], target: "dm", }, }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-expired-approval", data: "/approve 138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 26, text: "Approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const approvalCall = execApprovalCall(); expect(approvalCall.approvalId).toBe("138e9b8c"); expect(approvalCall.decision).toBe("allow-once"); expect(approvalCall.allowPluginFallback).toBe(true); expect(approvalCall.senderId).toBe("9"); expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); expect(replySpy).not.toHaveBeenCalled(); expect(sendMessageSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-expired-approval"); }); it("keeps plugin approval callback buttons for target-only recipients", async () => { onSpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); editMessageTextSpy.mockClear(); loadConfig.mockReturnValue({ approvals: { exec: { enabled: true, mode: "targets", targets: [{ channel: "telegram", to: "9" }], }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], capabilities: ["vision"], }, }, }); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-plugin-approve-blocked", data: "/approve plugin:138e9b8c allow-once", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 24, text: "Plugin approval required.", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve-blocked"); }); it("edits commands list for pagination callbacks", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-3", data: "commands_page_2:main", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 12, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const listCall = requireRecord( mockArg( listSkillCommandsForAgents as unknown as MockCallSource, 0, 0, "list skill commands call", ), "list skill commands call", ); expect(listCall.cfg).toBeTypeOf("object"); expect(listCall.agentIds).toEqual(["main"]); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); const [chatId, messageId, text, params] = mockCall( editMessageTextSpy as unknown as MockCallSource, 0, "edit message text", ); expect(chatId).toBe(1234); expect(messageId).toBe(12); expect(String(text)).toContain(`${INFO_EMOJI} Commands (2/`); expect(params).toEqual({ reply_markup: { inline_keyboard: [ [ { text: "◀ Prev", callback_data: "commands_page_1:main" }, { text: "2/6", callback_data: "commands_page_noop:main" }, { text: "Next ▶", callback_data: "commands_page_3:main" }, ], ], }, }); }); it("falls back to default agent for pagination callbacks without agent suffix", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-no-suffix", data: "commands_page_2", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 14, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); const listCall = requireRecord( mockArg( listSkillCommandsForAgents as unknown as MockCallSource, 0, 0, "list skill commands call", ), "list skill commands call", ); expect(listCall.cfg).toBeTypeOf("object"); expect(listCall.agentIds).toEqual(["main"]); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); }); it("ignores unsafe command pagination pages", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); editMessageTextSpy.mockClear(); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-unsafe-page", data: "commands_page_9007199254740993:main", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 16, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(listSkillCommandsForAgents).not.toHaveBeenCalled(); expect(editMessageTextSpy).not.toHaveBeenCalled(); }); it("blocks pagination callbacks when allowlist rejects sender", async () => { onSpy.mockClear(); editMessageTextSpy.mockClear(); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "pairing", capabilities: { inlineButtons: "allowlist" }, allowFrom: [], }, }, }, }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-4", data: "commands_page_2", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 13, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4"); }); it("routes compact model callbacks against the configured provider", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const modelId = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"; const storePath = `/tmp/openclaw-telegram-model-compact-${process.pid}-${Date.now()}.json`; const config: OpenClawConfig = { agents: { defaults: { model: `amazon-bedrock/${modelId}`, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; await rm(storePath, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-compact-1", data: `mdl_sel/${modelId}`, from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 14, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(String(firstEditMessageTextArg(2))).toContain( `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(String(firstEditMessageTextArg(2))).toContain( "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; expect(entry?.providerOverride).toBeUndefined(); expect(entry?.modelOverride).toBeUndefined(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-1"); } finally { await rm(storePath, { force: true }); } }); it("renders model callback lists with configured display names", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-model-display-names-${process.pid}-${Date.now()}.json`; const buildModelsProviderDataMock = telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType; buildModelsProviderDataMock.mockResolvedValueOnce({ byProvider: new Map>([["openai", new Set(["gpt-5", "gpt-4.1"])]]), providers: ["openai"], resolvedDefault: { provider: "openai", model: "gpt-5" }, modelNames: new Map([ ["openai/gpt-4.1", "GPT 4.1 Bridge"], ["openai/gpt-5", "GPT Five Bridge"], ]), }); const config = { agents: { defaults: { model: "openai/gpt-5", }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; await rm(storePath, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-display-names-1", data: "mdl_list_openai_1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 23, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); const params = firstEditMessageTextArg(3); const inlineKeyboard = ( params as { reply_markup?: { inline_keyboard?: Array>; }; } ).reply_markup?.inline_keyboard; expect(inlineKeyboard).toStrictEqual([ [{ text: "GPT 4.1 Bridge", callback_data: "mdl_sel_openai/gpt-4.1" }], [{ text: "GPT Five Bridge ✓", callback_data: "mdl_sel_openai/gpt-5" }], [{ text: "<< Back", callback_data: "mdl_back" }], ]); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-display-names-1"); } finally { await rm(storePath, { force: true }); } }); it("resets overrides when selecting the configured default model", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-model-default-${process.pid}-${Date.now()}.json`; const config: OpenClawConfig = { agents: { defaults: { model: "claude-opus-4-6", models: { "anthropic/claude-opus-4-6": {}, }, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, }; await rm(storePath, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-default-1", data: "mdl_sel_anthropic/claude-opus-4-6", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 16, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(String(firstEditMessageTextArg(2))).toContain( `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(String(firstEditMessageTextArg(2))).toContain( "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; expect(entry?.providerOverride).toBeUndefined(); expect(entry?.modelOverride).toBeUndefined(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-default-1"); } finally { await rm(storePath, { force: true }); } }); it("formats non-default model selection confirmations with Telegram HTML parse mode", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-model-html-${process.pid}-${Date.now()}.json`; await rm(storePath, { force: true }); try { const config = { agents: { defaults: { model: "anthropic/claude-opus-4-6", models: { "anthropic/claude-opus-4-6": {}, "openai/gpt-5.4": {}, }, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-html-1", data: "mdl_sel_openai/gpt-5.4", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 17, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); const editCall = mockCall( editMessageTextSpy as unknown as MockCallSource, 0, "edit message text", ); expect(editCall[0]).toBe(1234); expect(editCall[1]).toBe(17); expect(editCall[2]).toBe( `${CHECK_MARK_EMOJI} Model changed to openai/gpt-5.4\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime <runtime> to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`, ); expect(requireRecord(editCall[3], "edit params").parse_mode).toBe("HTML"); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; expect(entry?.providerOverride).toBe("openai"); expect(entry?.modelOverride).toBe("gpt-5.4"); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-html-1"); } finally { await rm(storePath, { force: true }); } }); it("persists non-default model override using fresh config, not stale startup snapshot", async () => { // Regression: the callback handler used the startup `cfg` snapshot for // store path and default-model resolution. If the config was reloaded // (e.g. default model changed) the override could be written to the wrong // store or incorrectly cleared because `isDefaultSelection` was wrong. onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); const storePath = `/tmp/openclaw-telegram-model-fresh-cfg-${process.pid}-${Date.now()}.json`; await rm(storePath, { force: true }); try { // Startup config: default is openai/gpt-5.4 const startupConfig = { agents: { defaults: { model: "openai/gpt-5.4", models: { "openai/gpt-5.4": {}, "anthropic/claude-opus-4-6": {}, }, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; // Fresh config: default changed to anthropic/claude-opus-4-6 const freshConfig = { ...startupConfig, agents: { defaults: { model: "anthropic/claude-opus-4-6", models: { "openai/gpt-5.4": {}, "anthropic/claude-opus-4-6": {}, }, }, }, }; // Bot created with startup config; loadConfig now returns fresh config loadConfig.mockReturnValue(freshConfig); createTelegramBot({ token: "tok", config: startupConfig, }); const callbackHandler = onSpy.mock.calls.find( (call) => call[0] === "callback_query", )?.[1] as (ctx: Record) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } // User selects openai/gpt-5.4 — was default at startup but NOT default // in fresh config. The override must be persisted. await callbackHandler({ callbackQuery: { id: "cbq-model-fresh-cfg-1", data: "mdl_sel_openai/gpt-5.4", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 20, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); // Override must be persisted (not cleared) because openai/gpt-5.4 is // NOT the default in the fresh config. const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; expect(entry?.providerOverride).toBe("openai"); expect(entry?.modelOverride).toBe("gpt-5.4"); } finally { await rm(storePath, { force: true }); } }); it("rejects ambiguous compact model callbacks and returns provider list", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); createTelegramBot({ token: "tok", config: { agents: { defaults: { model: "anthropic/shared-model", models: { "anthropic/shared-model": {}, "openai/shared-model": {}, }, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; if (!callbackHandler) { throw new Error("Expected Telegram callback_query handler"); } await callbackHandler({ callbackQuery: { id: "cbq-model-compact-2", data: "mdl_sel/shared-model", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 15, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(String(firstEditMessageTextArg(2))).toContain('Could not resolve model "shared-model".'); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-2"); }); it("includes sender identity in group envelope headers", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, 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: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); const { expectChannelInboundContextContract: expectInboundContextContract } = await loadInboundContextContract(); const { escapeRegExp, formatEnvelopeTimestamp } = await loadEnvelopeTimestampHelpers(); expectInboundContextContract(payload); const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); }); it("adds live chat and reply-target windows for stale group replies", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "Earlier deployment answer", date: 1736380200, message_id: 100, from: { id: 777, is_bot: true, first_name: "Assistant" }, }, }); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "Lunch after standup?", date: 1736380800, message_id: 200, from: { id: 201, is_bot: false, first_name: "Sam" }, }, }); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "After the incident review.", date: 1736380860, message_id: 201, from: { id: 202, is_bot: false, first_name: "Riley" }, }, }); replySpy.mockClear(); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "@openclaw_bot thoughts?", date: 1736380920, message_id: 202, from: { id: 203, is_bot: false, first_name: "Avery" }, reply_to_message: { chat: { id: 42, type: "group", title: "Ops" }, text: "Earlier deployment answer", date: 1736380200, message_id: 100, from: { id: 777, is_bot: true, first_name: "Assistant" }, }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); expect(contextRecord.label).toBe("Conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); expect(contextPayload.relation).toBe("selected_for_current_message"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const messagesById = new Map(messages.map((message) => [message.message_id, message])); expect(messagesById.get("100")?.sender).toBe("Assistant"); expect(messagesById.get("100")?.body).toBe("Earlier deployment answer"); expect(messagesById.get("100")?.is_reply_target).toBe(true); expect(messagesById.get("200")?.sender).toBe("Sam"); expect(messagesById.get("200")?.body).toBe("Lunch after standup?"); expect(messagesById.get("201")?.sender).toBe("Riley"); expect(messagesById.get("201")?.body).toBe("After the incident review."); }); it("keeps skipped group messages in default recent group history context", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "allowlist", groupAllowFrom: ["111", "222"], groups: { "*": { requireMention: true } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "Please run the maintenance step later.", date: 1736380800, message_id: 501, from: { id: 111, is_bot: false, first_name: "Requester" }, }, }); expect(replySpy).not.toHaveBeenCalled(); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "@openclaw_bot Hello", date: 1736380860, message_id: 502, from: { id: 222, is_bot: false, first_name: "Operator" }, entities: [{ type: "mention", offset: 0, length: 13 }], }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.UntrustedStructuredContext).toEqual([ { label: "Conversation context", payload: { messages: [ expect.objectContaining({ body: "Please run the maintenance step later.", sender: "Requester", }), ], order: "chronological", relation: "selected_for_current_message", }, source: "telegram", type: "chat_window", }, ]); }); it("excludes ambient transcript rows from live group conversation context", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "allowlist", groupAllowFrom: ["111", "222", "333", "444"], groups: { "*": { requireMention: true } }, }, }, }); const previousReadAmbient = telegramBotDepsForTest.readAmbientTranscriptWatermark; const previousResolveAmbientKey = telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey; telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey = vi.fn( () => "telegram:default:42", ); telegramBotDepsForTest.readAmbientTranscriptWatermark = vi.fn(() => ({ sessionId: "session-current", messageId: "502", timestampMs: 1_736_380_860_000, updatedAt: 1_736_380_900_000, })); try { createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; for (const message of [ { chat: { id: 42, type: "group", title: "Ops" }, text: "persisted ambient one", date: 1_736_380_800, message_id: 501, from: { id: 111, is_bot: false, first_name: "Requester" }, }, { chat: { id: 42, type: "group", title: "Ops" }, text: "persisted ambient two", date: 1_736_380_860, message_id: 502, from: { id: 222, is_bot: false, first_name: "Operator" }, }, { chat: { id: 42, type: "group", title: "Ops" }, text: "unpersisted gap", date: 1_736_380_920, message_id: 503, from: { id: 333, is_bot: false, first_name: "Mira" }, }, ]) { await handler({ ...baseCtx, message }); } expect(replySpy).not.toHaveBeenCalled(); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "@openclaw_bot what changed?", date: 1_736_380_980, message_id: 504, from: { id: 444, is_bot: false, first_name: "Pat" }, entities: [{ type: "mention", offset: 0, length: 13 }], }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextPayload = requireRecord( requireRecord(conversationContext, "conversation context").payload, "conversation context payload", ); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); expect(messages.map((message) => message.body)).toEqual(["unpersisted gap"]); } finally { telegramBotDepsForTest.readAmbientTranscriptWatermark = previousReadAmbient; telegramBotDepsForTest.resolveAmbientTranscriptWatermarkKey = previousResolveAmbientKey; } }); it("honors historyLimit zero for group chat-window context", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "allowlist", groupAllowFrom: ["111", "222"], historyLimit: 0, groups: { "*": { requireMention: true } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "Do not include this cached group line.", date: 1736380800, message_id: 601, from: { id: 111, is_bot: false, first_name: "Requester" }, }, }); expect(replySpy).not.toHaveBeenCalled(); await handler({ ...baseCtx, message: { chat: { id: 42, type: "group", title: "Ops" }, text: "@openclaw_bot Hello", date: 1736380860, message_id: 602, from: { id: 222, is_bot: false, first_name: "Operator" }, entities: [{ type: "mention", offset: 0, length: 13 }], }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.UntrustedStructuredContext).toBeUndefined(); expect(payload.Body).not.toContain("Do not include this cached group line."); }); it("updates cached bot messages from Telegram edit updates", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const editedHandler = getOnHandler("edited_message") as ( ctx: Record, ) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; const chat = { id: 42, type: "group", title: "Ops" }; const question = { chat, text: "/ask which bikes can reach 383kmph", date: 1778474813, message_id: 35014, from: { id: 201, is_bot: false, first_name: "Kesava" }, }; const fullAnswer = "Kawasaki Ninja H2R (claimed 400 km/h) and MTT 420RR turbine (claimed up to 439 km/h) exceed 383 km/h. Dodge Tomahawk reaches higher but is a 4-wheeled concept, not a standard bike."; await handler({ ...baseCtx, message: question, }); await handler({ ...baseCtx, message: { chat, text: "K", date: 1778474823, message_id: 35016, from: { id: 777, is_bot: true, first_name: "Super Serious Bot" }, reply_to_message: question, }, }); replySpy.mockClear(); await editedHandler({ ...baseCtx, editedMessage: { chat, text: fullAnswer, date: 1778474823, edit_date: 1778474824, message_id: 35016, from: { id: 777, is_bot: true, first_name: "Super Serious Bot" }, reply_to_message: question, }, }); expect(replySpy).not.toHaveBeenCalled(); await handler({ ...baseCtx, message: { chat, text: "wtf", date: 1778474850, message_id: 35018, from: { id: 202, is_bot: false, first_name: "Kesava" }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const messagesById = new Map(messages.map((message) => [message.message_id, message])); expect(messagesById.get("35016")?.sender).toBe("Super Serious Bot"); expect(messagesById.get("35016")?.body).toBe(fullAnswer); expect(messagesById.get("35016")?.body).not.toBe("K"); }); it("keeps direct Telegram media context when transcript context exists", async () => { onSpy.mockClear(); replySpy.mockClear(); const storePath = `/tmp/openclaw-telegram-dm-media-context-${process.pid}-${Date.now()}.json`; const config = { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; await rm(storePath, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await handler({ ...baseCtx, message: { chat: { id: 7771, type: "private" }, caption: "the reference image", date: 1778474800, message_id: 100, from: { id: 202, is_bot: false, first_name: "Kesava" }, photo: [{ file_id: "reference-photo-1", width: 1, height: 1 }], }, }); await writeDirectTelegramTranscriptContext({ cfg: config, storePath, chatId: 7771, senderId: 202, sessionId: "telegram-dm-media-context-session", text: "remember the launch checklist", timestamp: 1778474700000, }); replySpy.mockClear(); await handler({ ...baseCtx, message: { chat: { id: 7771, type: "private" }, text: "what about the image above?", date: 1778474850, message_id: 101, from: { id: 202, is_bot: false, first_name: "Kesava" }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); expect(messages.some((message) => message.body === "remember the launch checklist")).toBe( true, ); const photoMessage = messages.find((message) => message.message_id === "100"); expect(photoMessage?.body).toBe("the reference image"); expect(photoMessage?.media_ref).toBe("telegram:file/reference-photo-1"); } finally { await rm(storePath, { force: true }); } }); it("dedupes direct assistant transcript context against cached Telegram replies after stripping directives", async () => { onSpy.mockClear(); replySpy.mockClear(); const storePath = `/tmp/openclaw-telegram-dm-visible-dedupe-${process.pid}-${Date.now()}.json`; const config = { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; await rm(storePath, { force: true }); await rm(`${storePath}.telegram-messages.json`, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; const chatId = 7773; const senderId = 202; const visibleReply = "Yep - I'm here now."; const replyTimestampMs = 1_778_474_700_000; const telegramReplyDate = Math.floor((replyTimestampMs + 5_000) / 1000); await writeDirectTelegramTranscriptContext({ cfg: config, storePath, chatId, role: "assistant", senderId, sessionId: "telegram-dm-assistant-visible-dedupe-session", text: `[[reply_to_current]]${visibleReply}`, timestamp: replyTimestampMs, }); await handler({ ...baseCtx, message: { chat: { id: chatId, type: "private" }, text: "still there?", date: 1_778_474_850, message_id: 741, from: { id: senderId, is_bot: false, first_name: "Kesava" }, reply_to_message: { chat: { id: chatId, type: "private" }, date: telegramReplyDate, from: { id: 999, is_bot: true, first_name: "OpenClaw" }, message_id: 736, openclaw_prompt_context_timestamp_ms: replyTimestampMs, text: visibleReply, }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); expect(messages).toEqual([ expect.objectContaining({ body: visibleReply, is_reply_target: true, message_id: "736", sender: "OpenClaw", }), ]); expect(messages.filter((message) => message.body === visibleReply)).toHaveLength(1); expect(JSON.stringify(messages)).not.toContain("[[reply_to_current]]"); expect(messages.some((message) => String(message.message_id).startsWith("session:"))).toBe( false, ); } finally { await rm(storePath, { force: true }); await rm(`${storePath}.telegram-messages.json`, { force: true }); } }); it("skips direct transcript context for hard reset messages", async () => { onSpy.mockClear(); replySpy.mockClear(); const storePath = `/tmp/openclaw-telegram-dm-reset-context-${process.pid}-${Date.now()}.json`; const config = { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, session: { store: storePath, }, } satisfies NonNullable[0]["config"]>; await rm(storePath, { force: true }); try { loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); await writeDirectTelegramTranscriptContext({ cfg: config, storePath, chatId: 7772, senderId: 202, sessionId: "telegram-dm-reset-context-session", text: "old private transcript text", timestamp: 1778474700000, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), message: { chat: { id: 7772, type: "private" }, text: "/reset summarize my workspace", date: 1778474850, message_id: 101, from: { id: 202, is_bot: false, first_name: "Kesava" }, }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ); expect(JSON.stringify(payload.UntrustedStructuredContext ?? [])).not.toContain( "old private transcript text", ); } finally { await rm(storePath, { force: true }); } }); it("uses quote text when a Telegram partial reply is received", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); 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" }, }, quote: { text: " summarize this\n", position: 8, entities: [{ type: "bold", offset: 1, length: 9 }], }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("[Reply chain - nearest first]"); expect(payload.Body).toContain("[1. Ada id:9001]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); const telegramPayload = payload as Record; expect(telegramPayload.ReplyToQuoteText).toBe(" summarize this\n"); expect(telegramPayload.ReplyToQuotePosition).toBe(8); expect(telegramPayload.ReplyToQuoteEntities).toEqual([{ type: "bold", offset: 1, length: 9 }]); expect(telegramPayload.ReplyToQuoteSourceText).toBe("Can you summarize this?"); }); it("keeps reply linkage while omitting filtered binary reply captions", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); 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, caption: "PK\x00\x03\x04binary", from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("[Reply chain - nearest first]"); expect(payload.Body).toContain("[1. Ada id:9001]"); expect(payload.Body).not.toContain("PK"); expect(payload.Body).not.toContain("unsafe reply text omitted"); expect(payload.ReplyToBody).toBeUndefined(); expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToSender).toBe("Ada"); }); it("includes replied image media in inbound context for text replies", async () => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); loadWebMedia.mockResolvedValueOnce({ path: "/tmp/reply-photo.png", contentType: "image/png" }); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const ssrfMock = mockPinnedHostnameResolution(); try { createTelegramBot({ token: "tok", telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "what is in this image?", date: 1736380800, reply_to_message: { message_id: 9001, photo: [{ file_id: "reply-photo-1" }], from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); } finally { ssrfMock.mockRestore(); } expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ) as { MediaPath?: string; MediaPaths?: string[]; ReplyToBody?: string; }; expect(payload.ReplyToBody).toBe(""); expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1"); expect(loadWebMedia).not.toHaveBeenCalled(); expect(mediaFetch).toHaveBeenCalledTimes(1); }); it("hydrates reply chains from cached Telegram messages", async () => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const ssrfMock = mockPinnedHostnameResolution(); try { createTelegramBot({ token: "tok", telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, message_id: 9000, date: 1736380700, from: { id: 1, first_name: "Kesava" }, photo: [{ file_id: "root-photo-1" }], }, me: { username: "openclaw_bot" }, getFile: async () => ({ file_path: "media/root.jpg" }), }); await handler({ message: { chat: { id: 7, type: "private" }, message_id: 9001, text: "r u back from hermes", date: 1736380750, from: { id: 2, first_name: "Ada" }, reply_to_message: { message_id: 9000, photo: [{ file_id: "root-photo-1" }], from: { id: 1, first_name: "Kesava" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); replySpy.mockClear(); getFileSpy.mockClear(); mediaFetch.mockClear(); await handler({ message: { chat: { id: 7, type: "private" }, message_id: 9002, text: "why did you reply?", date: 1736380800, from: { id: 3, first_name: "Grace" }, reply_to_message: { message_id: 9001, text: "r u back from hermes", from: { id: 2, first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); } finally { ssrfMock.mockRestore(); } expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ) as { ReplyChain?: Array<{ messageId?: string; body?: string; mediaPath?: string; mediaRef?: string; replyToId?: string; }>; UntrustedStructuredContext?: unknown[]; }; expect(payload.ReplyChain).toHaveLength(2); expect(payload.ReplyChain?.[0]?.messageId).toBe("9001"); expect(payload.ReplyChain?.[0]?.body).toBe("r u back from hermes"); expect(payload.ReplyChain?.[0]?.replyToId).toBe("9000"); expect(payload.ReplyChain?.[1]?.messageId).toBe("9000"); expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string"); expect(payload.ReplyChain?.[1]?.mediaPath).toContain("/media/inbound/"); expect(payload.ReplyChain?.[1]?.mediaRef).toBeUndefined(); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const messagesById = new Map(messages.map((message) => [message.message_id, message])); expect(messagesById.get("9000")).toMatchObject({ sender: "Kesava", }); expect(messagesById.get("9000")?.media_path).toMatch(/^media:\/\/inbound\//); expect(messagesById.get("9000")?.media_path).not.toBe(payload.ReplyChain?.[1]?.mediaPath); expect(messagesById.get("9000")?.media_ref).toBeUndefined(); expect(getFileSpy).toHaveBeenCalledWith("root-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); }); it.each([ { name: "hydrates allowlisted group reply ancestors", allowFrom: ["1", "999"], expectHydrated: true, chatId: 7, }, { name: "does not hydrate unallowlisted group reply ancestors through quote override", allowFrom: ["1"], expectHydrated: false, chatId: 8, }, ])("$name", async ({ allowFrom, expectHydrated, chatId }) => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "open", contextVisibility: "allowlist_quote", allowFrom, }, }, }); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const ssrfMock = mockPinnedHostnameResolution(); try { createTelegramBot({ token: "tok", telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const baseCtx = { me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; const chat = { id: chatId, type: "group", title: "Ops" }; await handler({ ...baseCtx, message: { chat, message_id: 102, text: "Why is there a 4th person?", date: 1736380750, from: { id: 2, is_bot: false, first_name: "UserB" }, reply_to_message: { chat, message_id: 101, text: "Done, here is the image", date: 1736380700, from: { id: 999, is_bot: true, first_name: "OpenClaw" }, photo: [{ file_id: "generated-photo-1" }], }, }, }); replySpy.mockClear(); getFileSpy.mockClear(); mediaFetch.mockClear(); await handler({ ...baseCtx, message: { chat, message_id: 103, text: "@openclaw_bot explain what went wrong", date: 1736380800, from: { id: 1, is_bot: false, first_name: "UserA" }, reply_to_message: { chat, message_id: 102, text: "Why is there a 4th person?", date: 1736380750, from: { id: 2, is_bot: false, first_name: "UserB" }, }, }, }); } finally { ssrfMock.mockRestore(); } expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ) as { ReplyChain?: Array<{ messageId?: string; sender?: string; body?: string; mediaRef?: string; mediaPath?: string; }>; UntrustedStructuredContext?: unknown[]; }; expect(payload.ReplyChain?.map((entry) => entry.messageId)).toEqual(["102", "101"]); expect(payload.ReplyChain?.[1]).toMatchObject({ sender: "OpenClaw", body: "Done, here is the image", }); if (expectHydrated) { expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string"); expect(payload.ReplyChain?.[1]?.mediaPath).toContain("/media/inbound/"); expect(payload.ReplyChain?.[1]?.mediaRef).toBeUndefined(); } else { expect(payload.ReplyChain?.[1]?.mediaPath).toBeUndefined(); expect(payload.ReplyChain?.[1]?.mediaRef).toBe("telegram:file/generated-photo-1"); } const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const messagesById = new Map(messages.map((message) => [message.message_id, message])); expect(messagesById.get("101")).toMatchObject({ sender: "OpenClaw", body: "Done, here is the image", is_reply_target: true, }); if (expectHydrated) { expect(messagesById.get("101")?.media_path).toMatch(/^media:\/\/inbound\//); expect(messagesById.get("101")?.media_ref).toBeUndefined(); } else { expect(messagesById.get("101")?.media_path).toBeUndefined(); expect(messagesById.get("101")?.media_ref).toBe("telegram:file/generated-photo-1"); } expect(messagesById.get("102")).toMatchObject({ sender: "UserB", body: "Why is there a 4th person?", reply_to_id: "101", is_reply_target: true, }); if (expectHydrated) { expect(getFileSpy).toHaveBeenCalledWith("generated-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); } else { expect(getFileSpy).not.toHaveBeenCalled(); expect(mediaFetch).not.toHaveBeenCalled(); } }); it("does not hydrate reply media denied by General forum topic visibility", async () => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", contextVisibility: "allowlist", groups: { "-1007": { requireMention: false, allowFrom: ["1", "2"], topics: { "1": { allowFrom: ["1"], requireMention: false }, }, }, }, }, }, }); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const ssrfMock = mockPinnedHostnameResolution(); setTelegramRuntime({ state: { openKeyedStore: ((options) => createPluginStateKeyedStoreForTests( "telegram", options, )) as TelegramRuntime["state"]["openKeyedStore"], openSyncKeyedStore: ((options) => createPluginStateSyncKeyedStoreForTests( "telegram", options, )) as TelegramRuntime["state"]["openSyncKeyedStore"], }, channel: {}, } as TelegramRuntime); try { const replyDelivered = waitForReplyCalls(1); createTelegramBot({ token: "tok", telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const chat = { id: -1007, type: "supergroup", title: "Ops", is_forum: true }; await handler({ me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), message: { chat, message_id: 103, text: "explain this", date: 1736380800, from: { id: 1, is_bot: false, first_name: "Allowed" }, reply_to_message: { chat, message_id: 102, caption: "hidden image", date: 1736380750, from: { id: 2, is_bot: false, first_name: "Hidden" }, photo: [{ file_id: "hidden-photo-1" }], }, }, }); await replyDelivered; } finally { ssrfMock.mockRestore(); clearTelegramRuntime(); resetPluginStateStoreForTests(); } expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ) as { ReplyChain?: unknown[]; UntrustedStructuredContext?: unknown[]; }; expect(payload.ReplyChain).toBeUndefined(); const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const hiddenMessage = messages.find((message) => message.message_id === "102"); expect(hiddenMessage?.media_ref).toBe("telegram:file/hidden-photo-1"); expect(hiddenMessage?.media_path).toBeUndefined(); expect(getFileSpy).not.toHaveBeenCalled(); expect(mediaFetch).not.toHaveBeenCalled(); }); it.each([ { name: "hydrates group reply media allowed through an option-level access group", chatId: -1008, runtimeGroupAllowFrom: undefined, startupGroupAllowFrom: undefined, optionGroupAllowFrom: ["1", "accessGroup:operators"], useAccessGroup: true, expectHydrated: true, }, { name: "does not hydrate a sender removed from the refreshed runtime allowlist", chatId: -1009, runtimeGroupAllowFrom: ["1"], startupGroupAllowFrom: ["1", "2"], optionGroupAllowFrom: undefined, useAccessGroup: false, expectHydrated: false, }, ])( "$name", async ({ chatId, runtimeGroupAllowFrom, startupGroupAllowFrom, optionGroupAllowFrom, useAccessGroup, expectHydrated, }) => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); const runtimeConfig = { ...(useAccessGroup ? { accessGroups: { operators: { type: "message.senders" as const, members: { telegram: ["2"] }, }, }, } : {}), channels: { telegram: { groupPolicy: "open", contextVisibility: "allowlist", ...(runtimeGroupAllowFrom ? { groupAllowFrom: runtimeGroupAllowFrom } : {}), groups: { [String(chatId)]: { requireMention: false, }, }, }, }, } satisfies NonNullable[0]["config"]>; const startupConfig = { channels: { telegram: { groupPolicy: "open", ...(startupGroupAllowFrom ? { groupAllowFrom: startupGroupAllowFrom } : {}), groups: { [String(chatId)]: { requireMention: false } }, }, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(runtimeConfig); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const runtimeLog = vi.fn(); const runtimeError = vi.fn(); const runtimeExit = vi.fn(); const ssrfMock = mockPinnedHostnameResolution(); try { createTelegramBot({ token: "tok", config: startupConfig, ...(optionGroupAllowFrom ? { groupAllowFrom: optionGroupAllowFrom } : {}), runtime: { log: runtimeLog, error: runtimeError, exit: runtimeExit }, telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; const chat = { id: chatId, type: "group", title: "Ops" }; await handler({ me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), message: { chat, message_id: 103, text: "@openclaw_bot explain this", date: 1736380800, from: { id: 1, is_bot: false, first_name: "Allowed" }, reply_to_message: { chat, message_id: 102, caption: "allowed image", date: 1736380750, from: { id: 2, is_bot: false, first_name: "Also allowed" }, photo: [{ file_id: "allowed-photo-1" }], }, }, }); } finally { ssrfMock.mockRestore(); } expect(runtimeError).not.toHaveBeenCalled(); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 0, 0, "replySpy call", ) as { UntrustedStructuredContext?: unknown[]; }; const [conversationContext] = requireArray( payload.UntrustedStructuredContext, "structured context", ); const contextRecord = requireRecord(conversationContext, "conversation context"); const contextPayload = requireRecord(contextRecord.payload, "conversation context payload"); const messages = requireArray(contextPayload.messages, "conversation context messages").map( (message, index) => requireRecord(message, `conversation context message ${index + 1}`), ); const replyMessage = messages.find((message) => message.message_id === "102"); if (expectHydrated) { expect(replyMessage?.media_path).toMatch(/^media:\/\/inbound\//); expect(replyMessage?.media_ref).toBeUndefined(); expect(getFileSpy).toHaveBeenCalledWith("allowed-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); } else { expect(replyMessage?.media_path).toBeUndefined(); expect(replyMessage?.media_ref).toBe("telegram:file/allowed-photo-1"); expect(getFileSpy).not.toHaveBeenCalled(); expect(mediaFetch).not.toHaveBeenCalled(); } }, ); it("does not fetch reply media for unauthorized DM replies", async () => { onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); sendMessageSpy.mockClear(); readChannelAllowFromStore.mockResolvedValue([]); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "pairing", allowFrom: [], }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "hey", date: 1736380800, from: { id: 999, first_name: "Eve" }, reply_to_message: { message_id: 9001, photo: [{ file_id: "reply-photo-1" }], from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); expect(getFileSpy).not.toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled(); expect(sendMessageSpy).toHaveBeenCalledTimes(1); }); it("defers reply media download until debounce flush", async () => { const DEBOUNCE_MS = 4321; onSpy.mockClear(); replySpy.mockClear(); getFileSpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, messages: { inbound: { debounceMs: DEBOUNCE_MS, }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }); const mediaFetch = vi.fn( async () => new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200, headers: { "content-type": "image/png" }, }), ); const ssrfMock = mockPinnedHostnameResolution(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); try { const replyDelivered = waitForReplyCalls(1); createTelegramBot({ token: "tok", telegramTransport: { fetch: mediaFetch as typeof fetch, sourceFetch: mediaFetch as typeof fetch, close: async () => {}, }, }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "first", date: 1736380800, message_id: 101, from: { id: 42, first_name: "Ada" }, reply_to_message: { message_id: 9001, photo: [{ file_id: "reply-photo-1" }], from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); await handler({ message: { chat: { id: 7, type: "private" }, text: "second", date: 1736380801, message_id: 102, from: { id: 42, first_name: "Ada" }, reply_to_message: { message_id: 9001, photo: [{ file_id: "reply-photo-1" }], from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({}), }); expect(replySpy).not.toHaveBeenCalled(); expect(getFileSpy).not.toHaveBeenCalled(); const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex( (call) => call[1] === DEBOUNCE_MS, ); const flushTimer = flushTimerCallIndex >= 0 ? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined) : undefined; if (flushTimerCallIndex >= 0) { clearTimeout( setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType, ); } expect(flushTimer).toBeTypeOf("function"); await flushTimer?.(); await replyDelivered; expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1"); expect(mediaFetch).toHaveBeenCalled(); } finally { setTimeoutSpy.mockRestore(); ssrfMock.mockRestore(); } }); it("handles quote-only replies without reply metadata", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); 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, quote: { text: "summarize this", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("[Reply chain - nearest first]"); expect(payload.Body).toContain("[1. unknown sender"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBeUndefined(); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("unknown sender"); }); it("uses top-level quote text for external partial replies", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); 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, quote: { text: "summarize this", }, external_reply: { message_id: 9002, text: "Can you summarize this?", from: { first_name: "Ada" }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("[Reply chain - nearest first]"); expect(payload.Body).toContain("[1. Ada id:9002"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9002"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); expect((payload as Record).ReplyToIsExternal).toBe(true); }); it("propagates forwarded origin from external_reply targets", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "Thoughts?", date: 1736380800, external_reply: { message_id: 9003, text: "forwarded text", from: { first_name: "Ada" }, quote: { text: "forwarded snippet", }, forward_origin: { type: "user", sender_user: { id: 999, first_name: "Bob", last_name: "Smith", username: "bobsmith", is_bot: false, }, date: 500, }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.ReplyToForwardedFrom).toBe("Bob Smith (@bobsmith)"); expect(payload.ReplyToForwardedFromType).toBe("user"); expect(payload.ReplyToForwardedFromId).toBe("999"); expect(payload.ReplyToForwardedFromUsername).toBe("bobsmith"); expect(payload.ReplyToForwardedFromTitle).toBe("Bob Smith"); expect(payload.ReplyToForwardedDate).toBe(500000); expect(payload.Body).toContain( "[Forwarded from Bob Smith (@bobsmith) at 1970-01-01T00:08:20.000Z]", ); }); it("omits Date-invalid forwarded origin timestamps without dropping forwarded context", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "Thoughts?", date: 1736380800, external_reply: { message_id: 9004, text: "forwarded text", from: { first_name: "Ada" }, forward_origin: { type: "user", sender_user: { id: 999, first_name: "Bob", last_name: "Smith", username: "bobsmith", is_bot: false, }, date: 8_700_000_000_000, }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.ReplyToForwardedFrom).toBe("Bob Smith (@bobsmith)"); expect(payload.Body).toContain("[Forwarded from Bob Smith (@bobsmith)]"); expect(payload.Body).not.toContain("+275760"); }); it("redacts forwarded origin inside reply targets when context visibility is allowlist", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", contextVisibility: "allowlist", groups: { "-1007": { requireMention: false, allowFrom: ["1"], }, }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { message_id: 9004, chat: { id: -1007, type: "group", title: "Ops" }, text: "Thoughts?", date: 1736380800, from: { id: 1, first_name: "Ada", username: "ada", is_bot: false }, reply_to_message: { message_id: 9003, text: "forwarded text", from: { id: 1, first_name: "Ada", username: "ada", is_bot: false }, forward_origin: { type: "user", sender_user: { id: 999, first_name: "Bob", last_name: "Smith", username: "bobsmith", is_bot: false, }, date: 500, }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.ReplyToId).toBe("9003"); expect(payload.ReplyToBody).toBe("forwarded text"); expect(payload.ReplyToSender).toBe("Ada"); expect(payload.ReplyToForwardedFrom).toBeUndefined(); expect(payload.ReplyToForwardedFromType).toBeUndefined(); expect(payload.ReplyToForwardedFromId).toBeUndefined(); expect(payload.ReplyToForwardedFromUsername).toBeUndefined(); expect(payload.ReplyToForwardedDate).toBeUndefined(); expect(payload.Body).not.toContain("[Forwarded from Bob Smith (@bobsmith)"); }); it("accepts group replies to the bot without explicit mention when requireMention is enabled", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groups: { "*": { requireMention: true } } }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 456, type: "group", title: "Ops Chat" }, text: "following up", date: 1736380800, reply_to_message: { message_id: 42, text: "original reply", from: { id: 999, first_name: "OpenClaw" }, }, }, me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.WasMentioned).toBe(true); }); it("prefers topic allowFrom over group allowFrom", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-1001234567890": { allowFrom: ["123456789"], topics: { "99": { allowFrom: ["999999999"] }, }, }, }, }, }, }); 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: 123456789, username: "testuser" }, text: "hello", date: 1736380800, message_thread_id: 99, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(0); }); it("allows group messages for per-group groupPolicy open override (global groupPolicy allowlist)", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-100123456789": { groupPolicy: "open", requireMention: false, }, }, }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["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: 999999, username: "random" }, text: "hello", date: 1736380800, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); }); it("blocks control commands from unauthorized senders in per-group open groups", async () => { onSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-100123456789": { groupPolicy: "open", requireMention: false, }, }, }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["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: 999999, username: "random" }, text: "/status", date: 1736380800, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); }); it("routes plugin-owned callback namespaces before synthetic command fallback", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageTextSpy.mockClear(); sendMessageSpy.mockClear(); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: (async ({ respond, callback }: TelegramInteractiveHandlerContext) => { await respond.editMessage({ text: `Handled ${callback.payload}`, }); return { handled: true }; }) as never, }); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-codex-1", data: "codexapp:resume:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { business_connection_id: "biz-1", chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 11, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageTextSpy).toHaveBeenCalledWith(1234, 11, "Handled resume:thread-1", { business_connection_id: "biz-1", }); expect(replySpy).not.toHaveBeenCalled(); }); it("deletes plugin-owned callback messages through the bot API", async () => { onSpy.mockClear(); replySpy.mockClear(); deleteMessageSpy.mockClear(); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: (async ({ respond }: TelegramInteractiveHandlerContext) => { await respond.deleteMessage(); return { handled: true }; }) as never, }); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-codex-delete", data: "codexapp:delete:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 11, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(deleteMessageSpy).toHaveBeenCalledWith(1234, 11); expect(replySpy).not.toHaveBeenCalled(); }); it("routes plugin-owned callback replies with Telegram topic params", async () => { onSpy.mockClear(); replySpy.mockClear(); sendMessageSpy.mockClear(); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: (async ({ respond }: TelegramInteractiveHandlerContext) => { await respond.reply({ text: "Handled in topic" }); return { handled: true }; }) as never, }); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-codex-topic-reply", data: "codexapp:reply:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { business_connection_id: "biz-topic-1", chat: { id: -100987654321, type: "supergroup", title: "Forum Group" }, date: 1736380800, is_topic_message: true, message_id: 11, message_thread_id: 99, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(sendMessageSpy).toHaveBeenCalledWith(-100987654321, "Handled in topic", { business_connection_id: "biz-topic-1", message_thread_id: 99, }); sendMessageSpy.mockClear(); await callbackHandler({ callbackQuery: { id: "cbq-codex-general-reply", data: "codexapp:reply:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: -100987654322, type: "supergroup", title: "Forum Group" }, date: 1736380800, is_topic_message: true, message_id: 12, message_thread_id: 1, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(sendMessageSpy).toHaveBeenCalledWith(-100987654322, "Handled in topic", undefined); expect(replySpy).not.toHaveBeenCalled(); }); it("submits plugin-owned callback text through the Telegram inbound path", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); const replyDone = waitForReplyCalls(1); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler: async () => ({ handled: true, submitText: "Fix a broken tool" }), } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); await callbackHandler({ callbackQuery: { id: "cbq-smart-reply-submit", data: "openclaw-smart-replies:v1:Rm14IGEgYnJva2VuIHRvb2w", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 9, type: "private" }, date: 1736380800, message_id: 11, text: "What should I help you sharpen next?", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); await replyDone; } finally { clearTelegramRuntime(); } expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { reply_markup: { inline_keyboard: [] }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("Fix a broken tool"); expect(payload.SenderId).toBe("9"); expect(payload.SenderUsername).toBe("ada_bot"); }); it("does not submit plugin-owned callback text when the handler declines the callback", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); const handler = vi.fn(async () => ({ handled: false, submitText: "Ignore this" })); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler, } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); await callbackHandler({ callbackQuery: { id: "cbq-smart-reply-declined-submit", data: "openclaw-smart-replies:v1:SWdub3JlIHRoaXM", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 9, type: "private" }, date: 1736380800, message_id: 11, text: "Pick a direction", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); } finally { clearTelegramRuntime(); } expect(handler).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("callback_data: openclaw-smart-replies"); expect(payload.Body).not.toContain("Ignore this"); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); }); it("does not retry plugin-owned callback text skipped by inbound policy", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); const handler = vi.fn(async () => { // The callback was authorized before this policy change; submitText must // still honor the fresh inbound policy without releasing callback dedupe. loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], direct: { "9": { requireTopic: true } }, }, }, }); return { handled: true, submitText: "Do not submit this" }; }); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler, } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], capabilities: { inlineButtons: "dm" }, }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); const callbackContext = { callbackQuery: { id: "cbq-smart-reply-policy-skip", data: "openclaw-smart-replies:v1:RG8gbm90IHN1Ym1pdCB0aGlz", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 9, type: "private" }, date: 1736380800, message_id: 11, text: "Pick a direction", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; await expect(callbackHandler(callbackContext)).resolves.toBeUndefined(); await expect(callbackHandler(callbackContext)).resolves.toBeUndefined(); expect(handler).toHaveBeenCalledOnce(); expect(replySpy).not.toHaveBeenCalled(); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); } finally { clearTelegramRuntime(); } }); it("submits plugin-owned callback text in mention-required group topics", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); const replyDone = waitForReplyCalls(1); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler: async () => ({ handled: true, submitText: "Investigate topic callback" }), } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], capabilities: { inlineButtons: "group" }, groupPolicy: "open", groups: { "*": { requireMention: true } }, }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); await callbackHandler({ callbackQuery: { id: "cbq-smart-reply-topic-submit", data: "openclaw-smart-replies:v1:SW52ZXN0aWdhdGUgdG9waWMgY2FsbGJhY2s", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: -100987654321, type: "supergroup", title: "Forum Group", is_forum: true }, date: 1736380800, is_topic_message: true, message_id: 11, message_thread_id: 99, text: "What should I help you sharpen next?", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); await replyDone; } finally { clearTelegramRuntime(); } expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(-100987654321, 11, { reply_markup: { inline_keyboard: [] }, }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.Body).toContain("Investigate topic callback"); expect(payload.MessageSid).toBe("cbq-smart-reply-topic-submit"); expect(payload.WasMentioned).toBe(true); expect(payload.SenderId).toBe("9"); expect(payload.SenderUsername).toBe("ada_bot"); }); it("retries plugin-owned callback text when the previous reply session is still closing", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); let calls = 0; replySpy.mockImplementation(async (_ctx, opts) => { calls += 1; await opts?.onReplyStart?.(); if (calls === 1) { throw new Error("reply session initialization conflicted for agent:main:telegram:9"); } return undefined; }); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler: async () => ({ handled: true, submitText: "Make Alice funnier" }), } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); await callbackHandler({ callbackQuery: { id: "cbq-smart-reply-submit-retry", data: "openclaw-smart-replies:v1:TWFrZSBBbGljZSBmdW5uaWVy", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 9, type: "private" }, date: 1736380800, message_id: 11, text: "Pick a direction", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); } finally { clearTelegramRuntime(); } expect(replySpy).toHaveBeenCalledTimes(2); expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { reply_markup: { inline_keyboard: [] }, }); const payload = mockMsgContextArg( replySpy as unknown as MockCallSource, 1, 0, "replySpy retry call", ); expect(payload.Body).toContain("Make Alice funnier"); }); it("releases plugin-owned callback dedupe when submitted text processing fails", async () => { onSpy.mockClear(); replySpy.mockClear(); editMessageReplyMarkupSpy.mockClear(); let calls = 0; replySpy.mockImplementation(async (_ctx, opts) => { calls += 1; await opts?.onReplyStart?.(); if (calls === 1) { throw new Error("transient submit failure"); } return undefined; }); const handler = vi.fn(async () => ({ handled: true, submitText: "Try this later" })); registerPluginInteractiveHandler("smart-replies-plugin", { channel: "telegram", namespace: "openclaw-smart-replies", handler, } satisfies TelegramInteractiveHandlerRegistration); setTelegramPluginStateRuntimeForTests(); try { createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getTelegramCallbackHandlerForTests(); const createCallbackUpdate = (updateId: number) => ({ update_id: updateId, callbackQuery: { id: "cbq-smart-reply-submit-fail", data: "openclaw-smart-replies:v1:VHJ5IHRoaXMgbGF0ZXI", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 9, type: "private" }, date: 1736380800, message_id: 11, text: "Pick a direction", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); await expect(callbackHandler(createCallbackUpdate(401))).rejects.toThrow( "transient submit failure", ); expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); await callbackHandler(createCallbackUpdate(402)); } finally { clearTelegramRuntime(); } expect(handler).toHaveBeenCalledTimes(2); expect(replySpy).toHaveBeenCalledTimes(2); expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { reply_markup: { inline_keyboard: [] }, }); }); it("passes false command auth to Telegram plugin callbacks for non-allowlisted group senders", async () => { onSpy.mockClear(); let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined; const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => { observedAuth = auth; return { handled: true }; }); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: handler as never, }); const config = { commands: { allowFrom: { telegram: ["111111111"], }, }, channels: { telegram: { dmPolicy: "open", capabilities: { inlineButtons: "group" }, groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-plugin-auth-false", data: "codexapp:resume:thread-1", from: { id: 999999999, first_name: "Mallory", username: "mallory" }, message: { chat: { id: -100999, type: "supergroup", title: "Test Group" }, date: 1736380800, message_id: 22, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(handler).toHaveBeenCalledOnce(); expect(observedAuth?.isAuthorizedSender).toBe(false); }); it("passes true command auth to Telegram plugin callbacks for allowlisted group senders", async () => { onSpy.mockClear(); let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined; const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => { observedAuth = auth; return { handled: true }; }); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: handler as never, }); const config = { commands: { allowFrom: { telegram: ["111111111"], }, }, channels: { telegram: { dmPolicy: "open", capabilities: { inlineButtons: "group" }, groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-plugin-auth-true", data: "codexapp:resume:thread-1", from: { id: 111111111, first_name: "Ada", username: "ada" }, message: { chat: { id: -100999, type: "supergroup", title: "Test Group" }, date: 1736380800, message_id: 23, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(handler).toHaveBeenCalledOnce(); expect(observedAuth?.isAuthorizedSender).toBe(true); }); it("passes true command auth to Telegram plugin callbacks for access-group DM senders", async () => { onSpy.mockClear(); let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined; const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => { observedAuth = auth; return { handled: true }; }); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: handler as never, }); const config = { accessGroups: { operators: { type: "message.senders", members: { telegram: ["123456789"] }, }, }, channels: { telegram: { dmPolicy: "allowlist", allowFrom: ["accessGroup:operators"], capabilities: { inlineButtons: "dm" }, }, }, } satisfies NonNullable[0]["config"]>; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", config }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-plugin-access-group-auth", data: "codexapp:resume:thread-1", from: { id: 123456789, first_name: "Ada", username: "ada" }, message: { chat: { id: 123456789, type: "private" }, date: 1736380800, message_id: 24, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(handler).toHaveBeenCalledOnce(); expect(observedAuth?.isAuthorizedSender).toBe(true); }); it("routes Telegram #General callback payloads as topic 1 when Telegram omits topic metadata", async () => { onSpy.mockClear(); getChatSpy.mockResolvedValue({ id: -100123456789, type: "supergroup", is_forum: true }); const handler = vi.fn( async ({ respond, conversationId, threadId }: TelegramInteractiveHandlerContext) => { expect(conversationId).toBe("-100123456789:topic:1"); expect(threadId).toBe(1); await respond.editMessage({ text: `Handled ${conversationId}`, }); return { handled: true }; }, ); registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", handler: handler as never, }); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], }, }, }, }); const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; await callbackHandler({ callbackQuery: { id: "cbq-codex-general", data: "codexapp:resume:thread-1", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: -100123456789, type: "supergroup", title: "Forum Group" }, date: 1736380800, message_id: 11, text: "Select a thread", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(getChatSpy).toHaveBeenCalledWith(-100123456789); expect(handler).toHaveBeenCalledOnce(); expect(editMessageTextSpy).toHaveBeenCalledWith( -100123456789, 11, "Handled -100123456789:topic:1", undefined, ); }); it("keeps unconfigured dm topic commands on the flat dm session", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); replySpy.mockClear(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, message_thread_id: 99, }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.CommandTargetSessionKey).toBe("agent:main:main"); }); it("uses bot topic capability for native dm topic command target sessions", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); replySpy.mockClear(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, message_thread_id: 99, }, me: { has_topics_enabled: true }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99"); }); it("allows native DM commands for paired users", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); replySpy.mockClear(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); expect( sendMessageSpy.mock.calls.some( (call) => call[1] === "You are not authorized to use this command.", ), ).toBe(false); }); it("keeps native DM commands on the startup-resolved config when fresh reads contain SecretRefs", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); replySpy.mockClear(); replySpy.mockResolvedValue({ text: "response" }); const startupConfig = { commands: { native: true }, channels: { telegram: { dmPolicy: "pairing" as const, botToken: "resolved-token", }, }, }; createTelegramBot({ token: "tok", config: startupConfig, }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); }); it("blocks native DM commands for unpaired users", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); commandSpy.mockClear(); replySpy.mockClear(); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce([]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, }, match: "", }); expect(replySpy).not.toHaveBeenCalled(); expect(sendMessageSpy).toHaveBeenCalledWith( 12345, "You are not authorized to use this command.", {}, ); }); it("registers message_reaction handler", () => { onSpy.mockClear(); createTelegramBot({ token: "tok" }); const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction"); expect(reactionHandler?.[0]).toBe("message_reaction"); if (typeof reactionHandler?.[1] !== "function") { throw new Error("expected message_reaction handler"); } }); it("enqueues system event for reaction", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: THUMBS_UP_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(firstSystemEventArg(0)).toBe( `Telegram reaction added: ${THUMBS_UP_EMOJI} by Ada (@ada_bot) on msg 42`, ); expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:1234:42:9"); }); it.each([ { name: "blocks reaction when dmPolicy is disabled", updateId: 510, channelConfig: { dmPolicy: "disabled", reactionNotifications: "all" }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 0, }, { name: "blocks reaction in pairing mode for non-paired sender (default dmPolicy)", updateId: 514, channelConfig: { dmPolicy: "pairing", reactionNotifications: "all" }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 0, }, { name: "blocks reaction in allowlist mode for unauthorized direct sender", updateId: 511, channelConfig: { dmPolicy: "allowlist", allowFrom: ["12345"], reactionNotifications: "all", }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 0, }, { name: "allows reaction in allowlist mode for authorized direct sender", updateId: 512, channelConfig: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 1, }, { name: "blocks reaction in open mode when wildcard access was constrained", updateId: 515, channelConfig: { dmPolicy: "open", allowFrom: ["12345"], reactionNotifications: "all" }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 0, }, { name: "allows reaction in open mode with explicit wildcard access", updateId: 516, channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, expectedEnqueueCalls: 1, }, { name: "blocks reaction in group allowlist mode for unauthorized sender", updateId: 513, channelConfig: { dmPolicy: "open", groupPolicy: "allowlist", groupAllowFrom: ["12345"], reactionNotifications: "all", }, reaction: { chat: { id: 9999, type: "supergroup" }, message_id: 77, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: FIRE_EMOJI }], }, expectedEnqueueCalls: 0, }, ])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: channelConfig, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: updateId }, messageReaction: reaction, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls); }); it("skips reaction when reactionNotifications is off", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); 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: THUMBS_UP_EMOJI }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("defaults reactionNotifications to own", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 502 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 43, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("allows reaction in all mode regardless of message sender", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: PARTY_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`); expect(firstSystemEventArg(1)).toBeTypeOf("object"); }); it("skips reaction in own mode when message is not sent by bot", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: PARTY_EMOJI }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("allows reaction in own mode when message is sent by bot", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: PARTY_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("skips reaction from bot users", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: PARTY_EMOJI }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], 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: THUMBS_UP_EMOJI }], new_reaction: [], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("enqueues one event per added emoji reaction", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 505 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], new_reaction: [ { type: "emoji", emoji: THUMBS_UP_EMOJI }, { type: "emoji", emoji: FIRE_EMOJI }, { type: "emoji", emoji: PARTY_EMOJI }, ], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2); expect(enqueueSystemEventSpy.mock.calls.map((call) => call[0])).toEqual([ `Telegram reaction added: ${FIRE_EMOJI} by Ada on msg 42`, `Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 42`, ]); }); it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; // MessageReactionUpdated does not include message_thread_id in the Bot API, // so forum reactions always route to the general topic (1). await handler({ update: { update_id: 505 }, messageReaction: { chat: { id: 5678, type: "supergroup", is_forum: true }, message_id: 100, user: { id: 10, first_name: "Bob", username: "bob_user" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: FIRE_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(firstSystemEventArg(0)).toBe( `Telegram reaction added: ${FIRE_EMOJI} by Bob (@bob_user) on msg 100`, ); expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:5678:topic:1"); expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:5678:100:10"); }); it("uses correct session key for forum group reactions in general topic", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); 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: EYES_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${EYES_EMOJI} by Bob on msg 101`); expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:5678:topic:1"); expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:5678:101:10"); }); it("uses correct session key for regular group reactions without topic", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); 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: HEART_EMOJI }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(firstSystemEventArg(0)).toBe( `Telegram reaction added: ${HEART_EMOJI} by Charlie on msg 200`, ); expect(String(systemEventOptions().sessionKey)).toContain("telegram:group:9999"); expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:9999:200:11"); // Verify session key does NOT contain :topic: const eventOptions = firstSystemEventArg(1) as { sessionKey?: string; }; const sessionKey = eventOptions.sessionKey ?? ""; expect(sessionKey).not.toContain(":topic:"); }); it("blocks reaction in own mode when cache is warm and message not sent by bot", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); 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: 601 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 99, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); });