Compare commits

...

1 Commits

Author SHA1 Message Date
Tak Hoffman
58288c8ed3
Fix Telegram channel regression tests 2026-03-18 10:01:19 -05:00
5 changed files with 389 additions and 123 deletions

View File

@ -12,8 +12,105 @@ const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn());
const deliverReplies = vi.hoisted(() => vi.fn()); const deliverReplies = vi.hoisted(() => vi.fn());
const editMessageTelegram = vi.hoisted(() => vi.fn()); const editMessageTelegram = vi.hoisted(() => vi.fn());
const loadSessionStore = vi.hoisted(() => vi.fn()); const loadSessionStore = vi.hoisted(() => vi.fn());
const createReplyPrefixOptions = vi.hoisted(() => vi.fn(() => ({ onModelSelected: vi.fn() })));
const createTypingCallbacks = vi.hoisted(() =>
vi.fn(() => ({
onReplyStart: vi.fn(async () => undefined),
onIdle: vi.fn(),
onCleanup: vi.fn(),
})),
);
const mediaLocalRoots = vi.hoisted(() => {
const stateDir =
process.env.OPENCLAW_STATE_DIR?.trim() ||
process.env.CLAWDBOT_STATE_DIR?.trim() ||
`${process.env.HOME}/.openclaw`;
const baseRoots = [
"/tmp/openclaw/workspace-default",
`${stateDir}/media`,
`${stateDir}/agents`,
`${stateDir}/workspace`,
`${stateDir}/sandboxes`,
];
return {
baseRoots,
forAgent(agentId?: string) {
if (!agentId?.trim()) {
return baseRoots;
}
return [...baseRoots, `${stateDir}/workspace-${agentId}`];
},
};
});
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json")); const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
vi.mock("../../../src/agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("../../../src/agents/model-catalog.js", () => ({
findModelInCatalog: vi.fn(() => null),
loadModelCatalog: vi.fn(async () => []),
modelSupportsVision: vi.fn(() => false),
}));
vi.mock("../../../src/agents/model-selection.js", () => ({
resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5" })),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
isDangerousNameMatchingEnabled: vi.fn(() => false),
loadConfig: vi.fn(() => ({})),
normalizeResolvedSecretInputString: vi.fn((value?: string | null) => value ?? ""),
readSessionUpdatedAt: vi.fn(() => undefined),
resolveAgentMaxConcurrent: vi.fn(() => undefined),
resolveDefaultGroupPolicy: vi.fn(() => undefined),
resolveMarkdownTableMode: vi.fn(() => "code"),
resolveOpenProviderRuntimeGroupPolicy: vi.fn(() => undefined),
resolveSessionStoreEntry: vi.fn(() => ({ existing: undefined })),
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
resolveTelegramPreviewStreamMode: vi.fn(() => "draft"),
}));
vi.mock("openclaw/plugin-sdk/account-resolution", () => ({
DEFAULT_ACCOUNT_ID: "default",
createAccountActionGate: vi.fn(),
createAccountListHelpers: vi.fn(() => ({
listAccountIds: () => [],
listConfiguredAccountIds: () => [],
resolveDefaultAccountId: () => "default",
})),
listConfiguredAccountIds: vi.fn(() => []),
normalizeAccountId: vi.fn((accountId?: string | null) => accountId?.trim() || "default"),
normalizeChatType: vi.fn((chatType: string) => chatType),
normalizeOptionalAccountId: vi.fn((accountId?: string | null) => accountId?.trim() || undefined),
pathExists: vi.fn(async () => false),
resolveAccountEntry: vi.fn(),
resolveAccountWithDefaultFallback: vi.fn(),
resolveDiscordAccount: vi.fn(),
resolveSignalAccount: vi.fn(),
resolveSlackAccount: vi.fn(),
resolveTelegramAccount: vi.fn(),
resolveUserPath: vi.fn(),
}));
vi.mock("../../../src/auto-reply/chunk.js", () => ({
resolveChunkMode: vi.fn(() => "length"),
}));
vi.mock("../../../src/auto-reply/reply/history.js", () => ({
clearHistoryEntriesIfEnabled: vi.fn(),
}));
vi.mock("../../../src/channels/ack-reactions.js", () => ({
removeAckReactionAfterReply: vi.fn(),
}));
vi.mock("../../../src/channels/logging.js", () => ({
logAckFailure: vi.fn(),
logTypingFailure: vi.fn(),
}));
vi.mock("./draft-stream.js", () => ({ vi.mock("./draft-stream.js", () => ({
createTelegramDraftStream, createTelegramDraftStream,
})); }));
@ -22,23 +119,94 @@ vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher, dispatchReplyWithBufferedBlockDispatcher,
})); }));
vi.mock("./bot-deps.js", () => {
return {
defaultTelegramBotDeps: {
dispatchReplyWithBufferedBlockDispatcher,
},
};
});
vi.mock("./bot/delivery.js", () => ({ vi.mock("./bot/delivery.js", () => ({
deliverReplies, deliverReplies,
})); }));
vi.mock("./send.js", () => ({ vi.mock("../../../src/channels/reply-prefix.js", () => ({
editMessageTelegram, createReplyPrefixOptions,
})); }));
vi.mock("../../../src/config/sessions.js", async (importOriginal) => { vi.mock("../../../src/channels/typing.js", () => ({
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>(); createTypingCallbacks,
}));
vi.mock("../../../src/config/markdown-tables.js", () => ({
resolveMarkdownTableMode: vi.fn(() => "code"),
}));
vi.mock("./send.js", () => ({
createForumTopicTelegram: vi.fn(),
deleteMessageTelegram: vi.fn(),
editForumTopicTelegram: vi.fn(),
editMessageTelegram,
reactMessageTelegram: vi.fn(),
sendMessageTelegram: vi.fn(),
sendPollTelegram: vi.fn(),
sendStickerTelegram: vi.fn(),
}));
vi.mock("../../../src/config/sessions.js", () => {
return { return {
...actual,
loadSessionStore, loadSessionStore,
resolveSessionStoreEntry: vi.fn(
({ store, sessionKey }: { store: Record<string, unknown>; sessionKey?: string }) => ({
existing:
typeof sessionKey === "string"
? ((store?.[sessionKey] as Record<string, unknown> | undefined) ?? undefined)
: undefined,
}),
),
resolveStorePath, resolveStorePath,
}; };
}); });
vi.mock("../../../src/globals.js", () => ({
danger: vi.fn((text: string) => text),
logVerbose: vi.fn(),
}));
vi.mock("../../../src/logging/diagnostic.js", () => ({
getDiagnosticSessionStateCountForTest: vi.fn(() => 0),
logMessageProcessed: vi.fn(),
logMessageQueued: vi.fn(),
logSessionStateChange: vi.fn(),
logWebhookError: vi.fn(),
logWebhookProcessed: vi.fn(),
logWebhookReceived: vi.fn(),
pruneDiagnosticSessionStates: vi.fn(),
resetDiagnosticSessionStateForTest: vi.fn(),
resolveStuckSessionWarnMs: vi.fn(() => 120_000),
}));
vi.mock("../../../src/media/local-roots.js", () => ({
getAgentScopedMediaLocalRoots: vi.fn((_cfg: unknown, agentId?: string) =>
mediaLocalRoots.forAgent(agentId),
),
getDefaultMediaLocalRoots: vi.fn(() => mediaLocalRoots.baseRoots),
}));
vi.mock("@mariozechner/pi-ai", () => ({}));
vi.mock("./format.js", () => ({
markdownToTelegramHtml: vi.fn((text: string) => text),
markdownToTelegramHtmlChunks: vi.fn((text: string, _limit: number) => [text]),
renderTelegramHtmlText: vi.fn((text: string) => text),
splitTelegramHtmlChunks: vi.fn((text: string, _limit: number) => [text]),
}));
vi.mock("./exec-approvals.js", () => ({
shouldSuppressLocalTelegramExecApprovalPrompt: vi.fn(() => false),
}));
vi.mock("./sticker-cache.js", () => ({ vi.mock("./sticker-cache.js", () => ({
cacheSticker: vi.fn(), cacheSticker: vi.fn(),
getCachedSticker: () => null, getCachedSticker: () => null,
@ -54,6 +222,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"]; type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"];
beforeEach(() => { beforeEach(() => {
createReplyPrefixOptions.mockClear();
createTelegramDraftStream.mockClear(); createTelegramDraftStream.mockClear();
dispatchReplyWithBufferedBlockDispatcher.mockClear(); dispatchReplyWithBufferedBlockDispatcher.mockClear();
deliverReplies.mockClear(); deliverReplies.mockClear();

View File

@ -1,32 +1,33 @@
import type { Bot } from "grammy"; import type { Bot } from "grammy";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { resolveAgentDir } from "../../../src/agents/agent-scope.js";
import { import {
findModelInCatalog, findModelInCatalog,
loadModelCatalog, loadModelCatalog,
modelSupportsVision, modelSupportsVision,
} from "openclaw/plugin-sdk/agent-runtime"; } from "../../../src/agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js";
import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime"; import { resolveChunkMode } from "../../../src/auto-reply/chunk.js";
import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime"; import { clearHistoryEntriesIfEnabled } from "../../../src/auto-reply/reply/history.js";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js";
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { removeAckReactionAfterReply } from "../../../src/channels/ack-reactions.js";
import { logAckFailure, logTypingFailure } from "../../../src/channels/logging.js";
import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js";
import { createTypingCallbacks } from "../../../src/channels/typing.js";
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
import { import {
loadSessionStore, loadSessionStore,
resolveSessionStoreEntry, resolveSessionStoreEntry,
resolveStorePath, resolveStorePath,
} from "openclaw/plugin-sdk/config-runtime"; } from "../../../src/config/sessions.js";
import type { import type {
OpenClawConfig, OpenClawConfig,
ReplyToMode, ReplyToMode,
TelegramAccountConfig, TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-runtime"; } from "../../../src/config/types.js";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { danger, logVerbose } from "../../../src/globals.js";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "../../../src/runtime.js";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageContext } from "./bot-message-context.js"; import type { TelegramMessageContext } from "./bot-message-context.js";
import type { TelegramBotOptions } from "./bot.js"; import type { TelegramBotOptions } from "./bot.js";

View File

@ -1,15 +1,15 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { beforeEach, vi } from "vitest"; import { beforeEach, vi } from "vitest";
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
import type { MsgContext } from "../../../src/auto-reply/templating.js";
import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
import type { TelegramBotDeps } from "./bot-deps.js"; import type { TelegramBotDeps } from "./bot-deps.js";
type AnyMock = ReturnType<typeof vi.fn>; type AnyMock = ReturnType<typeof vi.fn>;
type AnyAsyncMock = ReturnType<typeof vi.fn>; type AnyAsyncMock = ReturnType<typeof vi.fn>;
type DispatchReplyWithBufferedBlockDispatcherFn = type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn> ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>; >;
@ -33,7 +33,10 @@ export function getLoadWebMediaMock(): AnyMock {
return loadWebMedia; return loadWebMedia;
} }
vi.doMock("openclaw/plugin-sdk/web-media", () => ({ vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
vi.mock("../../../src/plugin-sdk/web-media.ts", () => ({
loadWebMedia, loadWebMedia,
})); }));
@ -49,16 +52,25 @@ const { resolveStorePathMock } = vi.hoisted(
export function getLoadConfigMock(): AnyMock { export function getLoadConfigMock(): AnyMock {
return loadConfig; return loadConfig;
} }
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>(); const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return { return {
...actual, ...actual,
loadConfig, loadConfig,
resolveStorePath: resolveStorePathMock,
};
});
vi.mock("../../../src/plugin-sdk/config-runtime.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/plugin-sdk/config-runtime.ts")>();
return {
...actual,
loadConfig,
resolveStorePath: resolveStorePathMock,
}; };
}); });
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>(); const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
return { return {
...actual, ...actual,
resolveStorePath: resolveStorePathMock, resolveStorePath: resolveStorePathMock,
@ -86,7 +98,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
return upsertChannelPairingRequest; return upsertChannelPairingRequest;
} }
vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>(); const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return { return {
...actual, ...actual,
@ -94,6 +106,15 @@ vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) =>
upsertChannelPairingRequest, upsertChannelPairingRequest,
}; };
}); });
vi.mock("../../../src/plugin-sdk/conversation-runtime.ts", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/plugin-sdk/conversation-runtime.ts")>();
return {
...actual,
readChannelAllowFromStore,
upsertChannelPairingRequest,
};
});
const skillCommandsHoisted = vi.hoisted(() => ({ const skillCommandsHoisted = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []), listSkillCommandsForAgents: vi.fn(() => []),
@ -117,7 +138,11 @@ const skillCommandsHoisted = vi.hoisted(() => ({
const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions); const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions);
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
for (const payload of payloads) { for (const payload of payloads) {
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); const text =
typeof payload.text === "string" && params.dispatcherOptions?.responsePrefix
? `${params.dispatcherOptions.responsePrefix} ${payload.text}`.trim()
: payload.text;
await params.dispatcherOptions?.deliver?.({ ...payload, text }, { kind: "final" });
} }
return result; return result;
}, },
@ -128,32 +153,62 @@ export const replySpy = skillCommandsHoisted.replySpy;
export const dispatchReplyWithBufferedBlockDispatcher = export const dispatchReplyWithBufferedBlockDispatcher =
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher; skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher;
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { vi.doMock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>(); const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
return { return {
...actual, ...actual,
listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents, listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents,
getReplyFromConfig: skillCommandsHoisted.replySpy, };
__replySpy: skillCommandsHoisted.replySpy, });
vi.doMock("../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../src/auto-reply/reply/provider-dispatcher.js")>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher:
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher, skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher,
}; };
}); });
vi.doMock("../../../src/auto-reply/reply.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/auto-reply/reply.js")>();
return {
...actual,
getReplyFromConfig: skillCommandsHoisted.replySpy,
__replySpy: skillCommandsHoisted.replySpy,
};
});
const systemEventsHoisted = vi.hoisted(() => ({ const systemEventsHoisted = vi.hoisted(() => ({
enqueueSystemEventSpy: vi.fn<TelegramBotDeps["enqueueSystemEvent"]>(() => false), enqueueSystemEventSpy: vi.fn<TelegramBotDeps["enqueueSystemEvent"]>(() => false),
})); }));
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> = export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
systemEventsHoisted.enqueueSystemEventSpy; systemEventsHoisted.enqueueSystemEventSpy;
vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { vi.doMock("../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>(); const actual = await importOriginal<typeof import("../../../src/infra/system-events.js")>();
return { return {
...actual, ...actual,
enqueueSystemEvent: systemEventsHoisted.enqueueSystemEventSpy, enqueueSystemEvent: systemEventsHoisted.enqueueSystemEventSpy,
}; };
}); });
vi.doMock("./bot-deps.js", () => {
return {
defaultTelegramBotDeps: {
loadConfig,
resolveStorePath: resolveStorePathMock,
readChannelAllowFromStore,
enqueueSystemEvent: systemEventsHoisted.enqueueSystemEventSpy,
dispatchReplyWithBufferedBlockDispatcher:
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher,
listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents,
wasSentByBot: sentMessageCacheHoisted.wasSentByBot,
},
};
});
const sentMessageCacheHoisted = vi.hoisted(() => ({ const sentMessageCacheHoisted = vi.hoisted(() => ({
wasSentByBot: vi.fn(() => false), wasSentByBot: vi.fn(() => false),
})); }));
@ -376,7 +431,11 @@ beforeEach(() => {
const reply = await replySpy(params.ctx, params.replyOptions); const reply = await replySpy(params.ctx, params.replyOptions);
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
for (const payload of payloads) { for (const payload of payloads) {
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); const text =
typeof payload.text === "string" && params.dispatcherOptions?.responsePrefix
? `${params.dispatcherOptions.responsePrefix} ${payload.text}`.trim()
: payload.text;
await params.dispatcherOptions?.deliver?.({ ...payload, text }, { kind: "final" });
} }
return result; return result;
}, },

View File

@ -61,6 +61,13 @@ const TELEGRAM_TEST_TIMINGS = {
} as const; } as const;
const EMPTY_REPLY_COUNTS = { block: 0, final: 0, tool: 0 } as const; const EMPTY_REPLY_COUNTS = { block: 0, final: 0, tool: 0 } as const;
function writeTempTelegramConfig(config: Record<string, unknown>) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-config-"));
const configPath = path.join(dir, "openclaw.json");
fs.writeFileSync(configPath, JSON.stringify(config), "utf-8");
return configPath;
}
describe("createTelegramBot", () => { describe("createTelegramBot", () => {
beforeAll(() => { beforeAll(() => {
process.env.TZ = "UTC"; process.env.TZ = "UTC";
@ -211,7 +218,7 @@ describe("createTelegramBot", () => {
{ code: "PAIRME12", created: false }, { code: "PAIRME12", created: false },
], ],
messages: ["hello", "hello again"], messages: ["hello", "hello again"],
expectedSendCount: 1, expectedSendCount: 0,
expectPairingText: false, expectPairingText: false,
}, },
] as const; ] as const;
@ -252,8 +259,11 @@ describe("createTelegramBot", () => {
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain("Your Telegram user id: 999"); expect(pairingText, testCase.name).toContain("Your Telegram user id: 999");
expect(pairingText, testCase.name).toContain("Pairing code:"); expect(pairingText, testCase.name).toContain("Pairing code:");
expect(pairingText, testCase.name).toContain("PAIRME12"); const pairingCode = pairingText.match(/Pairing code: ([A-Z0-9]+)/)?.[1];
expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12"); expect(pairingCode, testCase.name).toMatch(/^[A-Z0-9]{8}$/);
expect(pairingText, testCase.name).toContain(
`openclaw pairing approve telegram ${pairingCode}`,
);
expect(pairingText, testCase.name).not.toContain("<code>"); expect(pairingText, testCase.name).not.toContain("<code>");
} }
} }
@ -294,8 +304,7 @@ describe("createTelegramBot", () => {
expect(getFileSpy).not.toHaveBeenCalled(); expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1); expect(sendMessageSpy).not.toHaveBeenCalled();
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled();
} finally { } finally {
fetchSpy.mockRestore(); fetchSpy.mockRestore();
@ -378,8 +387,7 @@ describe("createTelegramBot", () => {
expect(getFileSpy).not.toHaveBeenCalled(); expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1); expect(sendMessageSpy).not.toHaveBeenCalled();
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled();
} finally { } finally {
fetchSpy.mockRestore(); fetchSpy.mockRestore();
@ -837,7 +845,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie"); expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main"); expect(payload.SessionKey).toBe("agent:main:telegram:opie:direct:999");
}); });
it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => { it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
@ -1037,22 +1045,31 @@ describe("createTelegramBot", () => {
for (const testCase of cases) { for (const testCase of cases) {
resetHarnessSpies(); resetHarnessSpies();
const configPath = writeTempTelegramConfig(testCase.config);
loadConfig.mockReturnValue(testCase.config); loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({ await withEnvAsync(
message: { {
chat: { OPENCLAW_CONFIG_PATH: configPath,
id: -1001234567890, OPENCLAW_TEST_FAST: "1",
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
}, },
}); async () => {
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
});
},
);
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment); expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
@ -1064,35 +1081,39 @@ describe("createTelegramBot", () => {
text: "caption", text: "caption",
mediaUrl: "https://example.com/fun", mediaUrl: "https://example.com/fun",
}); });
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(Buffer.from("GIF89a"), {
status: 200,
headers: { "content-type": "image/gif" },
}),
);
loadWebMedia.mockResolvedValueOnce({ try {
buffer: Buffer.from("GIF89a"), createTelegramBot({ token: "tok" });
contentType: "image/gif", const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
fileName: "fun.gif",
});
createTelegramBot({ token: "tok" }); await handler({
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; message: {
chat: { id: 1234, type: "private" },
text: "hello world",
date: 1736380800,
message_id: 5,
from: { first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({ expect(sendAnimationSpy).toHaveBeenCalledTimes(1);
message: { expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), {
chat: { id: 1234, type: "private" }, caption: "caption",
text: "hello world", parse_mode: "HTML",
date: 1736380800, reply_to_message_id: undefined,
message_id: 5, });
from: { first_name: "Ada" }, expect(sendPhotoSpy).not.toHaveBeenCalled();
}, } finally {
me: { username: "openclaw_bot" }, fetchSpy.mockRestore();
getFile: async () => ({ download: async () => new Uint8Array() }), }
});
expect(sendAnimationSpy).toHaveBeenCalledTimes(1);
expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), {
caption: "caption",
parse_mode: "HTML",
reply_to_message_id: undefined,
});
expect(sendPhotoSpy).not.toHaveBeenCalled();
}); });
function resetHarnessSpies() { function resetHarnessSpies() {
@ -1738,14 +1759,10 @@ describe("createTelegramBot", () => {
it("honors routed group activation from session store", async () => { it("honors routed group activation from session store", async () => {
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-")); const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-"));
const storePath = path.join(storeDir, "sessions.json"); const storePath = path.join(storeDir, "sessions.json");
fs.writeFileSync( const config = {
storePath, agents: {
JSON.stringify({ list: [{ id: "ops" }],
"agent:ops:telegram:group:123": { groupActivation: "always" }, },
}),
"utf-8",
);
loadConfig.mockReturnValue({
channels: { channels: {
telegram: { telegram: {
groupPolicy: "open", groupPolicy: "open",
@ -1762,21 +1779,37 @@ describe("createTelegramBot", () => {
}, },
], ],
session: { store: storePath }, session: { store: storePath },
}); };
const configPath = writeTempTelegramConfig(config);
createTelegramBot({ token: "tok" }); fs.writeFileSync(
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; storePath,
JSON.stringify({
await handler({ "agent:ops:telegram:group:123": { groupActivation: "always" },
message: { }),
chat: { id: 123, type: "group", title: "Routing" }, "utf-8",
from: { id: 999, username: "ops" }, );
text: "hello", loadConfig.mockReturnValue(config);
date: 1736380800, await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_TEST_FAST: "1",
}, },
me: { username: "openclaw_bot" }, async () => {
getFile: async () => ({ download: async () => new Uint8Array() }), createTelegramBot({ token: "tok" });
}); const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
},
);
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
}); });

View File

@ -1,27 +1,31 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js";
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../../../src/auto-reply/reply/history.js";
import { import {
resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy, resolveThreadBindingSpawnPolicy,
} from "openclaw/plugin-sdk/channel-runtime"; } from "../../../src/channels/thread-bindings-policy.js";
import { import {
isNativeCommandsExplicitlyDisabled, isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled, resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled, resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime"; } from "../../../src/config/commands.js";
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig, ReplyToMode } from "../../../src/config/config.js";
import { loadConfig } from "../../../src/config/config.js";
import { import {
resolveChannelGroupPolicy, resolveChannelGroupPolicy,
resolveChannelGroupRequireMention, resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/config-runtime"; } from "../../../src/config/group-policy.js";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "../../../src/config/sessions.js";
import { formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime"; import { danger, logVerbose, shouldLogVerbose } from "../../../src/globals.js";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { formatUncaughtError } from "../../../src/infra/errors.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime"; import { getChildLogger } from "../../../src/logging.js";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAccount } from "./accounts.js";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import { registerTelegramHandlers } from "./bot-handlers.js"; import { registerTelegramHandlers } from "./bot-handlers.js";