import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; 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 { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; import type { TelegramBotDeps } from "./bot-deps.js"; type AnyMock = ReturnType; type AnyAsyncMock = ReturnType; type LoadConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").loadConfig; type LoadSessionStoreFn = typeof import("openclaw/plugin-sdk/config-runtime").loadSessionStore; type ResolveStorePathFn = typeof import("openclaw/plugin-sdk/config-runtime").resolveStorePath; type SessionStore = ReturnType; type TelegramBotRuntimeForTest = NonNullable< Parameters[0] >; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; type DispatchReplyHarnessParams = Parameters[0]; const EMPTY_REPLY_COUNTS: DispatchReplyWithBufferedBlockDispatcherResult["counts"] = { block: 0, final: 0, tool: 0, }; const { sessionStorePath } = vi.hoisted(() => ({ sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`, })); const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({ loadWebMedia: vi.fn(), })); export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); vi.mock("openclaw/plugin-sdk/web-media.js", () => ({ loadWebMedia, })); const { loadConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } = vi.hoisted( (): { loadConfig: MockFn; loadSessionStoreMock: MockFn; resolveStorePathMock: MockFn; sessionStoreEntries: { value: SessionStore }; } => ({ loadConfig: vi.fn(() => ({})), loadSessionStoreMock: vi.fn( (_storePath, _opts) => sessionStoreEntries.value, ), resolveStorePathMock: vi.fn( (storePath?: string) => storePath ?? sessionStorePath, ), sessionStoreEntries: { value: {} as SessionStore }, }), ); export function getLoadConfigMock(): AnyMock { return loadConfig; } export function getLoadSessionStoreMock(): AnyMock { return loadSessionStoreMock; } export function setSessionStoreEntriesForTest(entries: SessionStore) { sessionStoreEntries.value = JSON.parse(JSON.stringify(entries)) as SessionStore; } vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig, loadSessionStore: loadSessionStoreMock, resolveStorePath: resolveStorePathMock, }; }); const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted( (): { readChannelAllowFromStore: MockFn; upsertChannelPairingRequest: AnyAsyncMock; } => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true, })), }), ); export function getReadChannelAllowFromStoreMock(): AnyAsyncMock { return readChannelAllowFromStore; } export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readChannelAllowFromStore, upsertChannelPairingRequest, }; }); vi.doMock("openclaw/plugin-sdk/conversation-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readChannelAllowFromStore, upsertChannelPairingRequest, }; }); const skillCommandListHoisted = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); const modelProviderDataHoisted = vi.hoisted(() => ({ buildModelsProviderData: vi.fn(), })); const replySpyHoisted = vi.hoisted(() => ({ replySpy: vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { await opts?.onReplyStart?.(); return undefined; }) as MockFn< ( ctx: MsgContext, opts?: GetReplyOptions, configOverride?: OpenClawConfig, ) => Promise >, })); async function dispatchHarnessReplies( params: DispatchReplyHarnessParams, runReply: ( params: DispatchReplyHarnessParams, ) => Promise, ): Promise { await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); const reply = await runReply(params); const payloads: ReplyPayload[] = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; const dispatcher = createReplyDispatcher({ deliver: async (payload, info) => { await params.dispatcherOptions.deliver?.(payload, info); }, responsePrefix: params.dispatcherOptions.responsePrefix, enableSlackInteractiveReplies: params.dispatcherOptions.enableSlackInteractiveReplies, responsePrefixContextProvider: params.dispatcherOptions.responsePrefixContextProvider, responsePrefixContext: params.dispatcherOptions.responsePrefixContext, onHeartbeatStrip: params.dispatcherOptions.onHeartbeatStrip, onSkip: (payload, info) => { params.dispatcherOptions.onSkip?.(payload, info); }, onError: (err, info) => { params.dispatcherOptions.onError?.(err, info); }, }); let finalCount = 0; for (const payload of payloads) { if (dispatcher.sendFinalReply(payload)) { finalCount += 1; } } dispatcher.markComplete(); await dispatcher.waitForIdle(); return { queuedFinal: finalCount > 0, counts: { block: 0, final: finalCount, tool: 0, }, }; } const dispatchReplyHoisted = vi.hoisted(() => ({ dispatchReplyWithBufferedBlockDispatcher: vi.fn( async (params: DispatchReplyHarnessParams) => await dispatchHarnessReplies(params, async (dispatchParams) => { return await replySpyHoisted.replySpy(dispatchParams.ctx, dispatchParams.replyOptions); }), ), })); export const listSkillCommandsForAgents = skillCommandListHoisted.listSkillCommandsForAgents; const buildModelsProviderData = modelProviderDataHoisted.buildModelsProviderData; export const replySpy = replySpyHoisted.replySpy; export const dispatchReplyWithBufferedBlockDispatcher = dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher; function parseModelRef(raw: string): { provider?: string; model: string } { const trimmed = raw.trim(); if (!trimmed) { return { model: "" }; } const slashIndex = trimmed.indexOf("/"); if (slashIndex > 0 && slashIndex < trimmed.length - 1) { return { provider: trimmed.slice(0, slashIndex), model: trimmed.slice(slashIndex + 1), }; } return { model: trimmed }; } function createModelsProviderDataFromConfig(cfg: OpenClawConfig): { byProvider: Map>; providers: string[]; resolvedDefault: { provider: string; model: string }; modelNames: Map; } { const byProvider = new Map>(); const add = (providerRaw: string | undefined, modelRaw: string | undefined) => { const provider = providerRaw?.trim().toLowerCase(); const model = modelRaw?.trim(); if (!provider || !model) { return; } const existing = byProvider.get(provider) ?? new Set(); existing.add(model); byProvider.set(provider, existing); }; const resolvedDefault = resolveDefaultModelForAgent({ cfg }); add(resolvedDefault.provider, resolvedDefault.model); for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) { const parsed = parseModelRef(raw); add(parsed.provider ?? resolvedDefault.provider, parsed.model); } const providers = [...byProvider.keys()].toSorted(); return { byProvider, providers, resolvedDefault, modelNames: new Map() }; } vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, buildModelsProviderData, }; }); vi.doMock("openclaw/plugin-sdk/command-auth.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents, buildModelsProviderData, }; }); vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, }; }); vi.doMock("openclaw/plugin-sdk/reply-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getReplyFromConfig: replySpyHoisted.replySpy, __replySpy: replySpyHoisted.replySpy, dispatchReplyWithBufferedBlockDispatcher: dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher, }; }); const systemEventsHoisted = vi.hoisted(() => ({ enqueueSystemEventSpy: vi.fn(() => false), })); export const enqueueSystemEventSpy: MockFn = systemEventsHoisted.enqueueSystemEventSpy; const execApprovalHoisted = vi.hoisted(() => ({ resolveExecApprovalSpy: vi.fn(async () => undefined), })); export const resolveExecApprovalSpy = execApprovalHoisted.resolveExecApprovalSpy; vi.doMock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, enqueueSystemEvent: systemEventsHoisted.enqueueSystemEventSpy, }; }); const sentMessageCacheHoisted = vi.hoisted(() => ({ wasSentByBot: vi.fn(() => false), })); export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot; vi.doMock("./sent-message-cache.js", () => ({ wasSentByBot: sentMessageCacheHoisted.wasSentByBot, recordSentMessage: vi.fn(), clearSentMessageCache: vi.fn(), })); // All spy variables used inside vi.mock("grammy", ...) must be created via // vi.hoisted() so they are available when the hoisted factory runs, regardless // of module evaluation order across different test files. const grammySpies = vi.hoisted(() => ({ useSpy: vi.fn() as MockFn<(arg: unknown) => void>, middlewareUseSpy: vi.fn() as AnyMock, onSpy: vi.fn() as AnyMock, stopSpy: vi.fn() as AnyMock, commandSpy: vi.fn() as AnyMock, botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined), answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, sendChatActionSpy: vi.fn() as AnyMock, editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock, setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock, setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock, getMeSpy: vi.fn(async () => ({ username: "openclaw_bot", has_topics_enabled: true, })) as AnyAsyncMock, getChatSpy: vi.fn(async () => undefined) as AnyAsyncMock, sendMessageSpy: vi.fn(async () => ({ message_id: 77 })) as AnyAsyncMock, sendAnimationSpy: vi.fn(async () => ({ message_id: 78 })) as AnyAsyncMock, sendPhotoSpy: vi.fn(async () => ({ message_id: 79 })) as AnyAsyncMock, getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock, })); export const useSpy: MockFn<(arg: unknown) => void> = grammySpies.useSpy; export const middlewareUseSpy: AnyMock = grammySpies.middlewareUseSpy; export const onSpy: AnyMock = grammySpies.onSpy; export const stopSpy: AnyMock = grammySpies.stopSpy; export const commandSpy: AnyMock = grammySpies.commandSpy; export const botCtorSpy: MockFn< (token: string, options?: { client?: { fetch?: typeof fetch } }) => void > = grammySpies.botCtorSpy; export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy; export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy; export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy; export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy; export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy; export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy; export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy; export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy; export const getChatSpy: AnyAsyncMock = grammySpies.getChatSpy; export const sendMessageSpy: AnyAsyncMock = grammySpies.sendMessageSpy; export const sendAnimationSpy: AnyAsyncMock = grammySpies.sendAnimationSpy; export const sendPhotoSpy: AnyAsyncMock = grammySpies.sendPhotoSpy; export const getFileSpy: AnyAsyncMock = grammySpies.getFileSpy; const runnerHoisted = vi.hoisted(() => ({ sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { if (typeof next === "function") { await next(); } }), sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware), throttlerSpy: vi.fn(() => "throttler"), })); export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; export let sequentializeKey: ((ctx: unknown) => string) | undefined; export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { Bot: class { api = { config: { use: grammySpies.useSpy }, answerCallbackQuery: grammySpies.answerCallbackQuerySpy, sendChatAction: grammySpies.sendChatActionSpy, editMessageText: grammySpies.editMessageTextSpy, editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy, sendMessageDraft: grammySpies.sendMessageDraftSpy, setMessageReaction: grammySpies.setMessageReactionSpy, setMyCommands: grammySpies.setMyCommandsSpy, getMe: grammySpies.getMeSpy, getChat: grammySpies.getChatSpy, sendMessage: grammySpies.sendMessageSpy, sendAnimation: grammySpies.sendAnimationSpy, sendPhoto: grammySpies.sendPhotoSpy, getFile: grammySpies.getFileSpy, }; use = grammySpies.middlewareUseSpy; on = grammySpies.onSpy; stop = grammySpies.stopSpy; command = grammySpies.commandSpy; catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, ) { (grammySpies.botCtorSpy as unknown as (token: string, options?: unknown) => void)( token, options, ); } } as unknown as TelegramBotRuntimeForTest["Bot"], sequentialize: ((keyFn: (ctx: unknown) => string) => { sequentializeKey = keyFn; return ( runnerHoisted.sequentializeSpy as unknown as () => ReturnType< TelegramBotRuntimeForTest["sequentialize"] > )(); }) as unknown as TelegramBotRuntimeForTest["sequentialize"], apiThrottler: (() => ( runnerHoisted.throttlerSpy as unknown as () => unknown )()) as unknown as TelegramBotRuntimeForTest["apiThrottler"], }; export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig, loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"], resolveStorePath: resolveStorePathMock, readChannelAllowFromStore: readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"], upsertChannelPairingRequest: upsertChannelPairingRequest as TelegramBotDeps["upsertChannelPairingRequest"], enqueueSystemEvent: enqueueSystemEventSpy as TelegramBotDeps["enqueueSystemEvent"], dispatchReplyWithBufferedBlockDispatcher, loadWebMedia: loadWebMedia as TelegramBotDeps["loadWebMedia"], buildModelsProviderData: buildModelsProviderData as TelegramBotDeps["buildModelsProviderData"], listSkillCommandsForAgents: listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"], wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"], resolveExecApproval: resolveExecApprovalSpy as NonNullable< TelegramBotDeps["resolveExecApproval"] >, }; vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest); export const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; if (!handler) { throw new Error(`Missing handler for event: ${event}`); } return handler as (ctx: Record) => Promise; }; const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = { agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }; export function makeTelegramMessageCtx(params: { chat: { id: number; type: string; title?: string; is_forum?: boolean; }; from: { id: number; username?: string }; text: string; date?: number; messageId?: number; messageThreadId?: number; }) { return { message: { chat: params.chat, from: params.from, text: params.text, date: params.date ?? 1736380800, message_id: params.messageId ?? 42, ...(params.messageThreadId === undefined ? {} : { message_thread_id: params.messageThreadId }), }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }; } export function makeForumGroupMessageCtx(params?: { chatId?: number; threadId?: number; text?: string; fromId?: number; username?: string; title?: string; }) { return makeTelegramMessageCtx({ chat: { id: params?.chatId ?? -1001234567890, type: "supergroup", title: params?.title ?? "Forum Group", is_forum: true, }, from: { id: params?.fromId ?? 12345, username: params?.username ?? "testuser" }, text: params?.text ?? "hello", messageThreadId: params?.threadId, }); } beforeEach(() => { resetInboundDedupe(); loadConfig.mockReset(); loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); sessionStoreEntries.value = {}; loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value); resolveStorePathMock.mockReset(); resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath); loadWebMedia.mockReset(); readChannelAllowFromStore.mockReset(); readChannelAllowFromStore.mockResolvedValue([]); upsertChannelPairingRequest.mockReset(); upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true } as const); onSpy.mockReset(); commandSpy.mockReset(); stopSpy.mockReset(); useSpy.mockReset(); replySpy.mockReset(); replySpy.mockImplementation(async (_ctx: MsgContext, opts?: GetReplyOptions) => { await opts?.onReplyStart?.(); return undefined; }); resolveExecApprovalSpy.mockReset(); resolveExecApprovalSpy.mockResolvedValue(undefined); dispatchReplyWithBufferedBlockDispatcher.mockReset(); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async (params: DispatchReplyHarnessParams) => await dispatchHarnessReplies(params, async (dispatchParams) => { return await replySpy(dispatchParams.ctx, dispatchParams.replyOptions); }), ); sendAnimationSpy.mockReset(); sendAnimationSpy.mockResolvedValue({ message_id: 78 }); sendPhotoSpy.mockReset(); sendPhotoSpy.mockResolvedValue({ message_id: 79 }); sendMessageSpy.mockReset(); sendMessageSpy.mockResolvedValue({ message_id: 77 }); getFileSpy.mockReset(); getFileSpy.mockResolvedValue({ file_path: "media/file.jpg" }); setMessageReactionSpy.mockReset(); setMessageReactionSpy.mockResolvedValue(undefined); answerCallbackQuerySpy.mockReset(); answerCallbackQuerySpy.mockResolvedValue(undefined); sendChatActionSpy.mockReset(); sendChatActionSpy.mockResolvedValue(undefined); setMyCommandsSpy.mockReset(); setMyCommandsSpy.mockResolvedValue(undefined); getChatSpy.mockReset(); getChatSpy.mockResolvedValue(undefined); getMeSpy.mockReset(); getMeSpy.mockResolvedValue({ username: "openclaw_bot", has_topics_enabled: true, }); editMessageTextSpy.mockReset(); editMessageTextSpy.mockResolvedValue({ message_id: 88 }); editMessageReplyMarkupSpy.mockReset(); editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 }); sendMessageDraftSpy.mockReset(); sendMessageDraftSpy.mockResolvedValue(true); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReset(); wasSentByBot.mockReturnValue(false); listSkillCommandsForAgents.mockReset(); listSkillCommandsForAgents.mockReturnValue([]); buildModelsProviderData.mockReset(); buildModelsProviderData.mockImplementation(async (cfg: OpenClawConfig) => { return createModelsProviderDataFromConfig(cfg); }); middlewareUseSpy.mockReset(); runnerHoisted.sequentializeMiddleware.mockReset(); runnerHoisted.sequentializeMiddleware.mockImplementation(async (_ctx, next) => { if (typeof next === "function") { await next(); } }); sequentializeSpy.mockReset(); sequentializeSpy.mockImplementation(() => runnerHoisted.sequentializeMiddleware); botCtorSpy.mockReset(); sequentializeKey = undefined; });