Files
openclaw/extensions/telegram/src/bot.test.ts
Peter Steinberger 884fdba81c fix(telegram): local turn tests complete on macOS (#110894)
* test(channels): mock core turn dispatch for delivery traces

* test(telegram): scope bot tests to a fresh state dir

Assembled turns resolve session/agent bindings through the state DB; an
ambient operator Codex binding fails generation reclaim and drops the turn,
adding misleading noise to reply-wait timeouts.

* test(telegram): drive bot turn tests through a prepared-dispatch mock

Core's turn lifecycle hard-wires its own provider dispatcher for assembled
turns, so the harness's injected dispatcher never fires and every reply-wait
turn test hangs to timeout locally. Convert resolved turns to prepared ones
that dispatch through the harness mock, restoring the file's original
authored contract.

* test(telegram): mark trace token as synthetic

* test(telegram): centralize trace dispatch mocks

* test(telegram): preserve assembled dispatch wiring

* test(telegram): guard trace dispatch seam

* test(telegram): avoid duplicate turn lifecycle
2026-07-18 19:52:27 +01:00

7126 lines
228 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import { rm } 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 {
closeOpenClawStateDatabaseForTest,
createPluginStateKeyedStoreForTests,
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { listSessionEntries, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { buildTelegramApprovalCallbackData } from "./approval-callback-data.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "./interactive-dispatch.js";
import {
resolveTelegramMessageCachePersistentScopeKey,
resolveTelegramMessageCacheScope,
TELEGRAM_MESSAGE_CACHE_PERSISTENT_MAX_MESSAGES,
TELEGRAM_MESSAGE_CACHE_PERSISTENT_NAMESPACE,
} from "./message-cache.js";
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import { setTelegramRuntime } from "./runtime.js";
import { clearTelegramRuntimeForTest as clearTelegramRuntime } from "./runtime.test-support.js";
import type { TelegramRuntime } from "./runtime.types.js";
const questionGatewayHoisted = vi.hoisted(() => ({
resolveQuestionOverGatewaySpy: vi.fn(async () => ({
status: "answered" as const,
questionId: "target",
optionValue: "Production",
})),
}));
vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({
questionGatewayRuntime: {
resolveOption: questionGatewayHoisted.resolveQuestionOverGatewaySpy,
},
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
return {
...actual,
runChannelInboundEvent: async (params: RunParams) => {
// This file's turn tests were authored against the injected harness
// dispatcher. Assembled turns now dispatch through core's own provider
// dispatcher, which an extension test cannot intercept; convert each
// resolved turn to a prepared one that drives the harness dispatcher,
// while leaving the outer runner as the sole lifecycle owner.
const harness = await import("./bot.create-telegram-bot.test-harness.js");
const resolveTurn = params.adapter.resolveTurn;
return await actual.runChannelInboundEvent({
...params,
adapter: {
...params.adapter,
resolveTurn: async (input, eventClass, preflight) => {
const resolved = await resolveTurn(input, eventClass, preflight);
if (!("route" in resolved) || "runDispatch" in resolved) {
return resolved;
}
const plan: import("openclaw/plugin-sdk/channel-inbound").ChannelInboundTurnPlan =
resolved;
return {
...plan,
runDispatch: async () =>
await harness.dispatchReplyWithBufferedBlockDispatcher({
ctx: plan.ctxPayload,
cfg: plan.cfg,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver: plan.delivery.deliver,
onError: plan.delivery.onError,
},
toolsAllow: plan.toolsAllow,
replyOptions: plan.replyOptions,
replyResolver: plan.replyResolver,
}),
} as typeof resolved;
},
},
});
},
};
});
const {
answerCallbackQuerySpy,
commandSpy,
deleteMessageSpy,
dispatchReplyWithBufferedBlockDispatcher,
editMessageReplyMarkupSpy,
editMessageTextSpy,
enqueueSystemEventSpy,
getFileSpy,
getChatSpy,
getLoadConfigMock,
getLoadWebMediaMock,
getReadChannelAllowFromStoreMock,
getOnHandler,
listSkillCommandsForAgents,
onSpy,
replySpy,
resolveExecApprovalSpy,
sendMessageSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
wasSentByBot,
} = await import("./bot.create-telegram-bot.test-harness.js");
const { recordOutboundMessageForPromptContext } = await import("./outbound-message-context.js");
const { runWithTelegramSpooledReplayUpdate, runWithTelegramUpdateProcessingFrame } =
await import("./bot-processing-outcome.js");
let createTelegramBotBase: typeof import("./bot-core.js").createTelegramBotCore;
let createTelegramBot: (
opts: import("./bot.types.js").TelegramBotOptions,
) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
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}";
async function withTelegramSpooledReplayUpdate<T>(
update: object,
fn: () => Promise<T>,
): Promise<T> {
return (await runWithTelegramSpooledReplayUpdate(update, fn)).value;
}
function createSignal() {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((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<string, unknown>) => Promise<void>;
}
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<ReadonlyArray<unknown>>;
};
};
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
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;
}
type DirectTelegramTranscriptTestMessage = {
id: string;
role: "assistant" | "user";
text: string;
timestamp: number;
};
function readOnlySessionEntry(storePath: string) {
return listSessionEntries({ storePath })[0]?.entry;
}
async function writeDirectTelegramTranscriptMessages(params: {
cfg: OpenClawConfig;
storePath: string;
chatId: number;
senderId: number;
sessionId: string;
messages: DirectTelegramTranscriptTestMessage[];
}) {
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 upsertSessionEntry({
storePath: params.storePath,
sessionKey,
entry: {
sessionId: params.sessionId,
chatType: "direct",
channel: "telegram",
updatedAt: 1,
},
});
for (const message of params.messages) {
await appendSessionTranscriptMessageByIdentity({
agentId: "main",
storePath: params.storePath,
sessionId: params.sessionId,
sessionKey,
message: {
role: message.role,
content: message.text,
timestamp: message.timestamp,
},
eventId: message.id,
});
}
}
async function writeDirectTelegramTranscriptContext(params: {
cfg: OpenClawConfig;
storePath: string;
chatId: number;
role?: "assistant" | "user";
senderId: number;
sessionId: string;
text: string;
timestamp: number;
}) {
const role = params.role ?? "user";
await writeDirectTelegramTranscriptMessages({
...params,
messages: [
{
id: role === "assistant" ? "transcript-assistant-1" : "transcript-user-1",
role,
text: params.text,
timestamp: params.timestamp,
},
],
});
}
function latestConversationContextMessages(): Record<string, unknown>[] {
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
replySpy.mock.calls.length - 1,
0,
"replySpy call",
);
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextPayload = requireRecord(
requireRecord(conversationContext, "conversation context").payload,
"conversation context payload",
);
return requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
}
async function seedTelegramPromptContextMessages(params: {
storePath: string;
chatId: number;
messages: Array<{
messageId: number;
text: string;
date: number;
legacyPromptContextTimestampMs?: number;
projection?: unknown;
unversioned?: boolean;
}>;
}) {
setTelegramPluginStateRuntimeForTests();
const store = createPluginStateKeyedStoreForTests("telegram", {
namespace: TELEGRAM_MESSAGE_CACHE_PERSISTENT_NAMESPACE,
maxEntries: TELEGRAM_MESSAGE_CACHE_PERSISTENT_MAX_MESSAGES,
});
const scopeKey = resolveTelegramMessageCachePersistentScopeKey(
resolveTelegramMessageCacheScope(params.storePath),
);
for (const message of params.messages) {
await store.register(`${scopeKey}:default:${params.chatId}:${message.messageId}`, {
...(message.unversioned ? {} : { version: 1 }),
sourceMessage: {
chat: { id: params.chatId, type: "private" },
date: message.date,
from: { id: message.unversioned ? 0 : 999, is_bot: true, first_name: "OpenClaw" },
message_id: message.messageId,
text: message.text,
...(message.legacyPromptContextTimestampMs !== undefined
? {
openclaw_prompt_context_timestamp_ms: message.legacyPromptContextTimestampMs,
}
: {}),
},
...(message.projection !== undefined ? { promptContextProjection: message.projection } : {}),
});
}
}
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;
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
let scopedStateDir: string | undefined;
describe("createTelegramBot", () => {
beforeAll(async () => {
({ createTelegramBotCore: createTelegramBotBase } = await import("./bot-core.js"));
});
beforeAll(() => {
process.env.TZ = "UTC";
// Isolate persistent state from the operator's real ~/.openclaw: assembled
// turns resolve session/agent bindings through the state DB, and an ambient
// Codex session binding fails its generation reclaim, so the embedded agent
// drops the turn without replying and reply-wait tests hang to timeout.
closeOpenClawStateDatabaseForTest();
scopedStateDir = fs.realpathSync(
fs.mkdtempSync(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-telegram-bot-state-")),
);
process.env.OPENCLAW_STATE_DIR = scopedStateDir;
});
afterAll(() => {
if (ORIGINAL_TZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = ORIGINAL_TZ;
}
closeOpenClawStateDatabaseForTest();
if (ORIGINAL_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
if (scopedStateDir) {
fs.rmSync(scopedStateDir, { recursive: true, force: true });
}
});
beforeEach(() => {
setMyCommandsSpy.mockClear();
questionGatewayHoisted.resolveQuestionOverGatewaySpy.mockClear();
clearPluginInteractiveHandlers();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
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("dedupes outbound prompt-context sends with 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<string, unknown>) => Promise<void>;
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)" }),
]),
);
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.filter((message) => message.message_id === "700")).toEqual([
expect.objectContaining({
body: "Bot just replied",
sender: "OpenClaw (you)",
}),
]);
});
it.each([
{
caseName: "labels a raw bot reply on a cache miss",
replyFrom: {
id: 999,
is_bot: true,
first_name: "Provisioning",
last_name: "Placeholder",
username: "openclaw_bot",
},
expectedSender: "Configured Agent (you)",
omitMe: false,
senderBusinessBot: undefined,
chatId: 42,
replyMessageId: 800,
},
{
caseName: "does not trust a user-controlled self suffix",
replyFrom: {
id: 888,
is_bot: false,
first_name: "Alex (you)",
username: "alex",
},
expectedSender: "Alex (you) (Telegram sender)",
omitMe: false,
senderBusinessBot: undefined,
chatId: 43,
replyMessageId: 810,
},
{
caseName: "authenticates the sender bot for a Telegram Business reply",
replyFrom: {
id: 777,
is_bot: false,
first_name: "Business Account",
username: "business_account",
},
expectedSender: "Configured Agent (you)",
omitMe: false,
senderBusinessBot: {
id: 999,
is_bot: true,
first_name: "Telegram Bot Name",
username: "openclaw_bot",
},
chatId: 44,
replyMessageId: 820,
},
{
caseName: "falls back to startup bot metadata when context metadata is missing",
replyFrom: {
id: 999,
is_bot: true,
first_name: "Provisioning",
last_name: "Placeholder",
username: "openclaw_bot",
},
expectedSender: "Configured Agent (you)",
omitMe: true,
senderBusinessBot: undefined,
chatId: 45,
replyMessageId: 830,
},
])(
"$caseName",
async ({ replyFrom, expectedSender, omitMe, senderBusinessBot, chatId, replyMessageId }) => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-self-projection-${process.pid}-${chatId}.json`;
const cfg = {
channels: {
telegram: {
name: " Configured Agent ",
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: { store: storePath },
} satisfies OpenClawConfig;
loadConfig.mockReturnValue(cfg);
createTelegramBot({
token: "tok",
config: cfg,
botInfo: {
id: 999,
is_bot: true,
first_name: "Telegram Bot Name",
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,
},
});
try {
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
...(omitMe
? {}
: {
me: {
id: 999,
is_bot: true,
first_name: "Telegram Bot Name",
username: "openclaw_bot",
},
}),
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private", first_name: "Pat" },
text: "Following up",
date: 1_736_380_800,
message_id: replyMessageId + 1,
from: { id: 123, is_bot: false, first_name: "Pat" },
reply_to_message: {
chat: { id: chatId, type: "private", first_name: "Pat" },
date: 1_736_380_700,
from: replyFrom,
...(senderBusinessBot ? { sender_business_bot: senderBusinessBot } : {}),
message_id: replyMessageId,
text: "Earlier reply",
},
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
0,
0,
"replySpy call",
);
expect(payload.ReplyChain).toEqual([
expect.objectContaining({
messageId: String(replyMessageId),
sender: expectedSender,
senderId: String(replyFrom.id),
senderUsername: replyFrom.username,
}),
]);
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const messages = requireArray(
requireRecord(
requireRecord(conversationContext, "conversation context").payload,
"conversation context payload",
).messages,
"conversation context messages",
).map((message, index) =>
requireRecord(message, `conversation context message ${index + 1}`),
);
expect(
messages.find((message) => message.message_id === String(replyMessageId)),
).toMatchObject({
sender: expectedSender,
sender_id: String(replyFrom.id),
sender_username: replyFrom.username,
});
if (replyFrom.id === 999 || senderBusinessBot?.id === 999) {
const promptJson = JSON.stringify({ replyChain: payload.ReplyChain, messages });
expect(promptJson).not.toContain("Provisioning");
expect(promptJson).not.toContain("Placeholder");
}
} finally {
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
},
);
it("uses the live allowlist when authorizing callbacks", async () => {
onSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
loadConfig.mockClear();
const startupConfig = {
channels: {
telegram: {
dmPolicy: "pairing" as const,
capabilities: { inlineButtons: "allowlist" as const },
allowFrom: ["9"],
},
},
};
const liveConfig = {
channels: {
telegram: {
dmPolicy: "pairing" as const,
capabilities: { inlineButtons: "allowlist" as const },
allowFrom: [],
},
},
};
loadConfig.mockReturnValue(liveConfig);
createTelegramBot({
token: "tok",
config: startupConfig,
});
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
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");
expect(loadConfig).toHaveBeenCalledTimes(1);
});
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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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(listSessionEntries({ storePath })).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<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
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(listSessionEntries({ storePath })).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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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(listSessionEntries({ storePath })).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<string, unknown>,
) => Promise<void>;
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("keeps group question callbacks on the configured callback allowlist", async () => {
onSpy.mockClear();
answerCallbackQuerySpy.mockClear();
const config = {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["9"],
capabilities: { inlineButtons: "all" },
groupPolicy: "open",
groups: { "*": { requireMention: false, allowFrom: ["9"] } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getTelegramCallbackHandlerForTests();
await callbackHandler({
callbackQuery: {
id: "cbq-question-blocked",
data: "tgq1:ask_0123456789abcdef0123456789abcdef:1",
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(questionGatewayHoisted.resolveQuestionOverGatewaySpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-question-blocked");
});
it("replaces legacy approval controls with a visible terminal receipt", 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<string, unknown>,
) => Promise<void>;
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(editMessageTextSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, terminalText, editOptions] = mockCall(
editMessageTextSpy as unknown as MockCallSource,
0,
"edit terminal approval message",
);
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(terminalText).toContain("✅ Approval resolved here");
expect(terminalText).toContain("Result: Allowed once");
expect(terminalText).toContain("ID: 138e9b8c");
expect(editOptions).toEqual({ reply_markup: { inline_keyboard: [] } });
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
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.approvalKind).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(replySpy).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<string, unknown>,
) => Promise<void>;
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(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("uses explicit ownership and renders canonical truth on a losing typed surface", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
resolveExecApprovalSpy.mockResolvedValueOnce({
applied: false,
approval: {
id: "plugin:id-owned-by-exec",
urlPath: "/approve/plugin%3Aid-owned-by-exec",
createdAtMs: 1,
expiresAtMs: 60_000,
resolvedAtMs: 2,
reason: "user",
status: "allowed",
decision: "allow-once",
presentation: {
kind: "exec",
commandText: "echo canonical",
commandPreview: "echo canonical",
allowedDecisions: ["allow-once", "deny"],
},
},
});
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getTelegramCallbackHandlerForTests();
const callbackData = buildTelegramApprovalCallbackData({
type: "approval",
approvalId: "plugin:id-owned-by-exec",
approvalKind: "exec",
decision: "deny",
});
if (!callbackData) {
throw new Error("Expected typed approval callback data");
}
await callbackHandler({
callbackQuery: {
id: "cbq-typed-approval-loser",
data: callbackData,
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 24,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(execApprovalCall()).toMatchObject({
approvalId: "plugin:id-owned-by-exec",
approvalKind: "exec",
decision: "deny",
senderId: "9",
});
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
24,
[
" Approval already resolved",
"Canonical result: Allowed once",
"ID: plugin:id-owned-by-exec",
"",
"Command:",
"echo canonical",
].join("\n"),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-typed-approval-loser");
});
it("sends a canonical terminal receipt when the clicked approval message cannot be edited", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy.mockClear();
editMessageTextSpy.mockRejectedValueOnce(new Error("Bad Request: message can't be edited"));
resolveExecApprovalSpy.mockResolvedValueOnce({
applied: true,
approval: {
id: "fallback-receipt-id",
urlPath: "/approve/fallback-receipt-id",
createdAtMs: 1,
expiresAtMs: 60_000,
resolvedAtMs: 2,
reason: "user",
status: "denied",
decision: "deny",
presentation: {
kind: "exec",
commandText: "echo denied",
commandPreview: "echo denied",
allowedDecisions: ["allow-once", "deny"],
},
},
});
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackData = buildTelegramApprovalCallbackData({
type: "approval",
approvalId: "fallback-receipt-id",
approvalKind: "exec",
decision: "deny",
});
if (!callbackData) {
throw new Error("Expected typed approval callback data");
}
await getTelegramCallbackHandlerForTests()({
callbackQuery: {
id: "cbq-terminal-edit-fallback",
data: callbackData,
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() }),
});
const terminalText = [
"✅ Approval resolved here",
"Canonical result: Denied",
"ID: fallback-receipt-id",
"",
"Command:",
"echo denied",
].join("\n");
expect(editMessageTextSpy).toHaveBeenCalledWith(1234, 25, terminalText, {
reply_markup: { inline_keyboard: [] },
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 25, {
reply_markup: { inline_keyboard: [] },
});
expect(sendMessageSpy).toHaveBeenCalledWith(1234, terminalText, undefined);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-terminal-edit-fallback");
});
it("consumes malformed callbacks in the reserved approval namespace", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
enqueueSystemEventSpy.mockClear();
replySpy.mockClear();
resolveExecApprovalSpy.mockClear();
const pluginHandler = vi.fn(async () => ({ handled: true }));
registerPluginInteractiveHandler("reserved-approval-test", {
channel: "telegram",
namespace: "tga1",
handler: pluginHandler as never,
});
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
await getTelegramCallbackHandlerForTests()({
callbackQuery: {
id: "cbq-malformed-reserved-approval",
data: "tga1:e:x:req-1",
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() }),
});
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
26,
" Approval action unavailable\nThis button is invalid or no longer actionable.",
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(pluginHandler).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-malformed-reserved-approval");
});
it("terminalizes a stale legacy click from the canonical record without retrying owners", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
const alreadyResolved = Object.assign(new Error("approval already resolved"), {
gatewayCode: "INVALID_REQUEST",
details: { reason: "APPROVAL_ALREADY_RESOLVED" },
});
resolveExecApprovalSpy.mockRejectedValueOnce(alreadyResolved).mockResolvedValueOnce({
applied: false,
approval: {
id: "stale-legacy-id",
urlPath: "/approve/stale-legacy-id",
createdAtMs: 1,
expiresAtMs: 60_000,
resolvedAtMs: 2,
reason: "user",
status: "denied",
decision: "deny",
presentation: {
kind: "exec",
commandText: "echo denied",
allowedDecisions: ["allow-once", "deny"],
},
},
});
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
await getTelegramCallbackHandlerForTests()({
callbackQuery: {
id: "cbq-stale-legacy",
data: "/approve stale-legacy-id 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() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(execApprovalCall(0)).toMatchObject({
approvalId: "stale-legacy-id",
approvalKind: "exec",
});
expect(execApprovalCall(1)).toMatchObject({
approvalId: "stale-legacy-id",
approvalKind: "exec",
});
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
25,
expect.stringContaining("Canonical result: Denied"),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-stale-legacy");
});
it("renders neutral terminal copy when a stale legacy record cannot be fetched", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
const alreadyResolved = Object.assign(new Error("approval already resolved"), {
gatewayCode: "INVALID_REQUEST",
details: { reason: "APPROVAL_ALREADY_RESOLVED" },
});
resolveExecApprovalSpy
.mockRejectedValueOnce(alreadyResolved)
.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" });
await getTelegramCallbackHandlerForTests()({
callbackQuery: {
id: "cbq-stale-legacy-neutral",
data: "/approve stale-neutral-id deny",
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() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
26,
expect.stringContaining(
"It was already resolved or expired; the canonical decision is unavailable here.",
),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
});
it("retries a stale legacy click when canonical convergence fails transiently", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
const alreadyResolved = Object.assign(new Error("approval already resolved"), {
gatewayCode: "INVALID_REQUEST",
details: { reason: "APPROVAL_ALREADY_RESOLVED" },
});
resolveExecApprovalSpy
.mockRejectedValueOnce(alreadyResolved)
.mockRejectedValueOnce(new Error("gateway unavailable"));
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
await expect(
getTelegramCallbackHandlerForTests()({
callbackQuery: {
id: "cbq-stale-legacy-retry",
data: "/approve stale-retry-id deny",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 27,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
}),
).rejects.toThrow("gateway unavailable");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-stale-legacy-retry");
});
it("resolves legacy opaque plugin ids without inferring kind from id spelling", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.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 = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-approve",
data: "/approve opaque-plugin-approval-id 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("opaque-plugin-approval-id");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(execApprovalCall(1)).toMatchObject({
approvalId: "opaque-plugin-approval-id",
approvalKind: "plugin",
decision: "allow-once",
senderId: "9",
});
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
24,
expect.stringContaining("✅ Approval resolved here"),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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 target-only exec resolution despite a misleading plugin id prefix", 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<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-approve-target",
data: "/approve plugin:misleading-exec-id 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("plugin:misleading-exec-id");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(1);
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
23,
expect.stringContaining("✅ Approval resolved here"),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
});
it("preserves ambiguous target-only stale callbacks for another approver", 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<string, unknown>,
) => Promise<void>;
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.approvalKind).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(1);
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
});
it("renders a terminal no-longer-pending receipt for expired legacy callbacks", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy
.mockRejectedValueOnce(new Error("unknown or expired approval id"))
.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<string, unknown>,
) => Promise<void>;
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.approvalKind).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(execApprovalCall(1).approvalKind).toBe("plugin");
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
26,
expect.stringContaining(" Approval no longer pending"),
{ reply_markup: { inline_keyboard: [] } },
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-expired-approval");
});
it("does not call canonical resolution with a guessed kind after a legacy miss", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.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: ["*"],
capabilities: ["vision"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
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(execApprovalCall()).toMatchObject({
approvalId: "plugin:138e9b8c",
approvalKind: "exec",
decision: "allow-once",
senderId: "9",
});
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(1);
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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();
const config = {
channels: {
telegram: {
dmPolicy: "pairing" as const,
capabilities: { inlineButtons: "allowlist" as const },
allowFrom: [],
},
},
};
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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 = readOnlySessionEntry(storePath);
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<typeof vi.fn>;
buildModelsProviderDataMock.mockResolvedValueOnce({
byProvider: new Map<string, Set<string>>([["openai", new Set(["gpt-5", "gpt-4.1"])]]),
providers: ["openai"],
resolvedDefault: { provider: "openai", model: "gpt-5" },
modelNames: new Map<string, string>([
["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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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<Array<{ text?: string; callback_data?: string }>>;
};
}
).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<string, unknown>) => Promise<void>;
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 = readOnlySessionEntry(storePath);
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<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
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 <b>openai/gpt-5.4</b>\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime &lt;runtime&gt; 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 = readOnlySessionEntry(storePath);
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("keeps hot-reloaded model pins on the next assembled turn", 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`;
const debounceMs = 4321;
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: ["*"],
},
},
messages: { inbound: { debounceMs } },
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
// Fresh config: default changed and GPT-5.6 Luna was added after startup.
const freshConfig = {
...startupConfig,
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"openai/gpt-5.4": {},
"openai/gpt-5.6-luna": {},
"anthropic/claude-opus-4-6": {},
},
},
},
};
const authorizationConfig = { ...freshConfig };
// 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<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
// The old startup default is no longer the live default, so selecting it
// must persist an override instead of being cleared as inherited.
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 = readOnlySessionEntry(storePath);
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
expect(entry?.modelOverrideSource).toBe("user");
// A model added after startup must also resolve and become the new user pin.
await callbackHandler({
callbackQuery: {
id: "cbq-model-fresh-cfg-2",
data: "mdl_sel_openai/gpt-5.6-luna",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380801,
message_id: 21,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const lunaEntry = readOnlySessionEntry(storePath);
expect(lunaEntry?.providerOverride).toBe("openai");
expect(lunaEntry?.modelOverride).toBe("gpt-5.6-luna");
expect(lunaEntry?.modelOverrideSource).toBe("user");
dispatchReplyWithBufferedBlockDispatcher.mockClear();
replySpy.mockClear();
loadConfig.mockClear();
loadConfig
.mockImplementationOnce(() => authorizationConfig)
.mockImplementationOnce(() => freshConfig)
.mockReturnValue(startupConfig);
const messageHandler = getOnHandler("message") as (
ctx: Record<string, unknown>,
) => Promise<void>;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const replyDelivered = waitForReplyCalls(1);
await messageHandler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: 1234, type: "private" },
text: "use the selected model",
date: 1_736_380_802,
message_id: 22,
from: { id: 9, is_bot: false, first_name: "Ada", username: "ada_bot" },
},
});
expect(loadConfig).toHaveBeenCalledTimes(1);
const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
(call) => call[1] === debounceMs,
);
const flushTimer =
flushTimerCallIndex >= 0
? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined)
: undefined;
if (flushTimerCallIndex >= 0) {
clearTimeout(
setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType<typeof setTimeout>,
);
}
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await replyDelivered;
} finally {
setTimeoutSpy.mockRestore();
}
expect(loadConfig).toHaveBeenCalledTimes(2);
const dispatchParams = mockArg(
dispatchReplyWithBufferedBlockDispatcher as unknown as MockCallSource,
0,
0,
"buffered dispatch",
) as { cfg?: OpenClawConfig };
expect(dispatchParams.cfg).toBe(freshConfig);
const afterTurn = readOnlySessionEntry(storePath);
expect(afterTurn?.providerOverride).toBe("openai");
expect(afterTurn?.modelOverride).toBe("gpt-5.6-luna");
expect(afterTurn?.modelOverrideSource).toBe("user");
} 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<string, unknown>,
) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
const editedHandler = getOnHandler("edited_message") as (
ctx: Record<string, unknown>,
) => Promise<void>;
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<Parameters<typeof createTelegramBot>[0]["config"]>;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
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 an unversioned outbound row by its explicit legacy prompt timestamp", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-legacy-dedupe-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7772;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
role: "assistant",
senderId,
sessionId: "telegram-legacy-outbound-dedupe",
text: "Legacy answer",
timestamp: transcriptTimestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 735,
text: "Legacy answer",
date: transcriptTimestampMs / 1000 + 5,
legacyPromptContextTimestampMs: transcriptTimestampMs,
unversioned: true,
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 740,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
const messages = latestConversationContextMessages();
expect(messages.filter((message) => message.body === "Legacy answer")).toEqual([
expect.objectContaining({ message_id: "735" }),
]);
expect(messages.some((message) => String(message.message_id).startsWith("session:"))).toBe(
false,
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
}
});
it("does not infer projection provenance from a current markerless outbound row", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-markerless-assistant-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7771;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
role: "assistant",
senderId,
sessionId: "telegram-markerless-assistant",
text: "same visible answer",
timestamp: transcriptTimestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 734,
text: "same visible answer",
date: transcriptTimestampMs / 1000,
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 739,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
const messages = latestConversationContextMessages();
expect(
messages
.filter((message) => message.body === "same visible answer")
.map((message) => message.message_id),
).toEqual(["session:transcript-assistant-1", "734"]);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
}
});
it("dedupes a Markdown assistant transcript against its complete Telegram projection", 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<Parameters<typeof createTelegramBot>[0]["config"]>;
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
try {
loadConfig.mockReturnValue(config);
const chatId = 7773;
const senderId = 202;
const visibleReply = "Important: use const.";
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]]**Important**: use `const`.",
timestamp: replyTimestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 736,
text: visibleReply,
date: telegramReplyDate,
projection: {
transcriptMessageId: "transcript-assistant-1",
partIndex: 0,
finalPart: true,
},
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const baseCtx = {
me: { id: 999, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
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,
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 (you)",
}),
]);
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 {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
});
it.each([
{
name: "part zero",
parts: [{ messageId: 751, text: "gamma", partIndex: 1, finalPart: true }],
},
{
name: "a middle part",
parts: [
{ messageId: 752, text: "Alpha", partIndex: 0, finalPart: false },
{ messageId: 754, text: "gamma", partIndex: 2, finalPart: true },
],
},
{
name: "the final marker",
parts: [
{ messageId: 755, text: "Alpha", partIndex: 0, finalPart: false },
{ messageId: 756, text: "beta", partIndex: 1, finalPart: false },
],
},
])("keeps the full assistant transcript when $name is missing", async ({ parts }) => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-incomplete-projection-${process.pid}-${parts[0]?.messageId}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const caseId = parts[0]?.messageId ?? 0;
const chatId = 10_000 + caseId;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
role: "assistant",
senderId,
sessionId: `telegram-incomplete-${parts[0]?.messageId}`,
text: "**Alpha** beta gamma",
timestamp: transcriptTimestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: parts.map((part) => ({
messageId: part.messageId,
text: part.text,
date: Math.floor(transcriptTimestampMs / 1000) + part.partIndex + 1,
projection: {
transcriptMessageId: "transcript-assistant-1",
partIndex: part.partIndex,
finalPart: part.finalPart,
},
})),
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
replySpy.mockClear();
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: caseId + 100,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const messages = latestConversationContextMessages();
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
body: "**Alpha** beta gamma",
message_id: "session:transcript-assistant-1",
}),
...parts.map((part) =>
expect.objectContaining({ body: part.text, message_id: String(part.messageId) }),
),
]),
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
});
it("preserves cached multipart provenance on a markerless Telegram reply target", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-complete-multipart-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7783;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
try {
loadConfig.mockReturnValue(config);
setTelegramPluginStateRuntimeForTests();
createTelegramBot({ token: "tok", config });
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
role: "assistant",
senderId,
sessionId: "telegram-complete-multipart",
text: "**Alpha** beta",
timestamp: transcriptTimestampMs,
});
for (const part of [
{ messageId: 781, text: "Alpha", partIndex: 0, finalPart: false },
{ messageId: 782, text: "beta", partIndex: 1, finalPart: true },
]) {
await recordOutboundMessageForPromptContext({
cfg: config,
account: { accountId: "default", name: "OpenClaw" },
chatId,
message: {
message_id: part.messageId,
date: transcriptTimestampMs / 1000 + part.partIndex + 1,
text: part.text,
},
messageId: part.messageId,
text: part.text,
promptContextProjection: {
transcriptMessageId: "transcript-assistant-1",
partIndex: part.partIndex,
finalPart: part.finalPart,
},
});
}
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
replySpy.mockClear();
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 783,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
reply_to_message: {
chat: { id: chatId, type: "private" },
date: transcriptTimestampMs / 1000 + 1,
from: { id: 999, is_bot: true, first_name: "OpenClaw" },
message_id: 781,
text: "Alpha",
},
},
});
const messages = latestConversationContextMessages();
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ body: "Alpha", is_reply_target: true, message_id: "781" }),
expect.objectContaining({ body: "beta", message_id: "782" }),
]),
);
expect(messages.filter((message) => message.message_id === "781")).toHaveLength(1);
expect(messages.filter((message) => message.message_id === "782")).toHaveLength(1);
expect(messages.some((message) => String(message.message_id).startsWith("session:"))).toBe(
false,
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
});
it("keeps an assistant transcript when persisted projection metadata is invalid", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-invalid-projection-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7784;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
role: "assistant",
senderId,
sessionId: "telegram-invalid-projection",
text: "same visible answer",
timestamp: transcriptTimestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 790,
text: "same visible answer",
date: transcriptTimestampMs / 1000,
projection: {
transcriptMessageId: "transcript-assistant-1",
partIndex: 0,
finalPart: true,
},
},
{
messageId: 791,
text: "cached trailing part",
date: transcriptTimestampMs / 1000 + 1,
projection: {
transcriptMessageId: "transcript-assistant-1",
partIndex: 1,
finalPart: "true",
},
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
replySpy.mockClear();
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 792,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
const messages = latestConversationContextMessages();
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
body: "same visible answer",
message_id: "session:transcript-assistant-1",
}),
expect.objectContaining({ body: "same visible answer", message_id: "790" }),
expect.objectContaining({ body: "cached trailing part", message_id: "791" }),
]),
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
});
it("keeps identical visible replies correlated to distinct transcript identities", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-repeat-projection-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7781;
const senderId = 202;
const transcriptTimestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptMessages({
cfg: config,
storePath,
chatId,
senderId,
sessionId: "telegram-repeat-projection",
messages: [
{
id: "assistant-repeat-1",
role: "assistant",
text: "**same answer**",
timestamp: transcriptTimestampMs,
},
{
id: "assistant-repeat-2",
role: "assistant",
text: "_same answer_",
timestamp: transcriptTimestampMs,
},
],
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 761,
text: "same answer",
date: transcriptTimestampMs / 1000 + 1,
projection: {
transcriptMessageId: "assistant-repeat-1",
partIndex: 0,
finalPart: true,
},
},
{
messageId: 762,
text: "same answer",
date: transcriptTimestampMs / 1000 + 2,
projection: {
transcriptMessageId: "assistant-repeat-2",
partIndex: 0,
finalPart: true,
},
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
replySpy.mockClear();
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 763,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
const messages = latestConversationContextMessages();
expect(
messages
.filter((message) => message.body === "same answer")
.map((message) => message.message_id),
).toEqual(["761", "762"]);
expect(messages.some((message) => String(message.message_id).startsWith("session:"))).toBe(
false,
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
await rm(storePath, { force: true });
await rm(`${storePath}.telegram-messages.json`, { force: true });
}
});
it("does not collapse literal user Markdown into markerless plain cache text", async () => {
onSpy.mockClear();
replySpy.mockClear();
const storePath = `/tmp/openclaw-telegram-dm-user-markdown-${process.pid}-${Date.now()}.json`;
const config = {
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
const chatId = 7782;
const senderId = 202;
const timestampMs = 1_778_474_700_000;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
await writeDirectTelegramTranscriptContext({
cfg: config,
storePath,
chatId,
senderId,
sessionId: "telegram-user-markdown",
text: "**literal user text**",
timestamp: timestampMs,
});
await seedTelegramPromptContextMessages({
storePath,
chatId,
messages: [
{
messageId: 771,
text: "literal user text",
date: timestampMs / 1000,
},
],
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
replySpy.mockClear();
await handler({
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
message: {
chat: { id: chatId, type: "private" },
text: "continue",
date: 1_778_474_850,
message_id: 772,
from: { id: senderId, is_bot: false, first_name: "Kesava" },
},
});
const messages = latestConversationContextMessages();
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
body: "**literal user text**",
message_id: "session:transcript-user-1",
}),
expect.objectContaining({ body: "literal user text", message_id: "771" }),
]),
);
} finally {
clearTelegramRuntime();
resetPluginStateStoreForTests();
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<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>;
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<string, unknown>) => Promise<void>;
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();
const botShutdown = new AbortController();
const mediaAbort = new AbortController();
let replyGetFileSignal: AbortSignal | undefined;
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",
fetchAbortSignal: botShutdown.signal,
mediaAbortSignal: mediaAbort.signal,
telegramTransport: {
fetch: mediaFetch as typeof fetch,
sourceFetch: mediaFetch as typeof fetch,
close: async () => {},
},
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
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 () => ({}),
});
replyGetFileSignal = mockArg(
getFileSpy as unknown as MockCallSource,
0,
1,
"reply getFile signal",
) as AbortSignal;
expect(replyGetFileSignal.aborted).toBe(false);
} finally {
mediaAbort.abort();
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("<media:image>");
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1", expect.any(AbortSignal));
expect(replyGetFileSignal?.aborted).toBe(true);
expect(botShutdown.signal.aborted).toBe(false);
botShutdown.abort();
expect(loadWebMedia).not.toHaveBeenCalled();
expect(mediaFetch).toHaveBeenCalledTimes(1);
});
it("dispatches the current text when best-effort reply media times out", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const timeout = Object.assign(new Error("media response headers timed out"), {
name: "TimeoutError",
});
const mediaFetch = vi.fn(async () => {
throw timeout;
});
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<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "continue without the old 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(mediaFetch).toHaveBeenCalledTimes(1);
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1", expect.any(AbortSignal));
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("continue without the old image");
});
it("dispatches the current text when classic polling aborts reply media", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const botShutdown = new AbortController();
getFileSpy.mockImplementationOnce(async () => {
botShutdown.abort();
throw Object.assign(new Error("aborted"), { name: "AbortError" });
});
createTelegramBot({ token: "tok", fetchAbortSignal: botShutdown.signal });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const { result } = await runWithTelegramUpdateProcessingFrame(() =>
handler({
message: {
chat: { id: 7, type: "private" },
text: "continue after polling restart",
date: 1736380800,
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
}),
);
expect(result).toEqual({ kind: "completed" });
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1", expect.any(AbortSignal));
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("continue after polling restart");
});
it("durably retries a spooled reply when shutdown aborts reply media", async () => {
onSpy.mockClear();
replySpy.mockClear();
getFileSpy.mockClear();
const botShutdown = new AbortController();
const mediaAbort = new AbortController();
getFileSpy.mockImplementationOnce(async () => {
botShutdown.abort();
throw new Error("Bad Request: file is too big");
});
createTelegramBot({
token: "tok",
fetchAbortSignal: botShutdown.signal,
mediaAbortSignal: mediaAbort.signal,
});
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const update = {
update_id: 98081,
message: {
chat: { id: 7, type: "private" },
text: "keep the old image",
date: 1736380800,
reply_to_message: {
message_id: 9001,
photo: [{ file_id: "reply-photo-1" }],
from: { first_name: "Ada" },
},
},
};
const { result } = await runWithTelegramUpdateProcessingFrame(() =>
withTelegramSpooledReplayUpdate(update, () =>
handler({
update,
message: update.message,
me: { username: "openclaw_bot" },
getFile: async () => ({}),
}),
),
);
expect(result).toEqual({ kind: "failed-retryable", error: expect.any(Error) });
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1", expect.any(AbortSignal));
expect(replySpy).not.toHaveBeenCalled();
expect(mediaAbort.signal.aborted).toBe(false);
});
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<string, unknown>) => Promise<void>;
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.any(AbortSignal));
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<string, unknown>) => Promise<void>;
const baseCtx = {
me: { id: 999, is_bot: true, first_name: "OpenClaw", 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 (you)",
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 (you)",
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.any(AbortSignal));
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<string, unknown>) => Promise<void>;
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<Parameters<typeof createTelegramBot>[0]["config"]>;
const startupConfig = {
channels: {
telegram: {
groupPolicy: "open",
...(startupGroupAllowFrom ? { groupAllowFrom: startupGroupAllowFrom } : {}),
groups: { [String(chatId)]: { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[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<string, unknown>) => Promise<void>;
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.any(AbortSignal));
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<typeof setTimeout>,
);
}
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await replyDelivered;
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1", expect.any(AbortSignal));
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>).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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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("settles spooled plugin callback text after a reply-session conflict retry succeeds", 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();
const 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",
},
};
const update = { update_id: 403, callback_query: callbackQuery };
const callbackContext = {
update,
callbackQuery,
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
const replay = await runWithTelegramSpooledReplayUpdate(update, async () => {
await callbackHandler(callbackContext);
});
expect(replay.deferredWork).toBeDefined();
await expect(replay.deferredWork?.task).resolves.toEqual({ kind: "completed" });
} 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<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
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<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
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<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>) => Promise<void>)
| 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<string, unknown>) => Promise<void>)
| 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<string, unknown>) => Promise<void>)
| 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<string, unknown>) => Promise<void>)
| 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<string, unknown>) => Promise<void>)
| 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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
// 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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */