openclaw/extensions/telegram/src/bot.create-telegram-bot.tes...

564 lines
20 KiB
TypeScript

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<typeof vi.fn>;
type AnyAsyncMock = ReturnType<typeof vi.fn>;
type LoadConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").loadConfig;
type ResolveStorePathFn = typeof import("openclaw/plugin-sdk/config-runtime").resolveStorePath;
type TelegramBotRuntimeForTest = NonNullable<
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
>;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[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, resolveStorePathMock } = vi.hoisted(
(): {
loadConfig: MockFn<LoadConfigFn>;
resolveStorePathMock: MockFn<ResolveStorePathFn>;
} => ({
loadConfig: vi.fn<LoadConfigFn>(() => ({})),
resolveStorePathMock: vi.fn<ResolveStorePathFn>(
(storePath?: string) => storePath ?? sessionStorePath,
),
}),
);
export function getLoadConfigMock(): AnyMock {
return loadConfig;
}
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig,
resolveStorePath: resolveStorePathMock,
};
});
const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
(): {
readChannelAllowFromStore: MockFn<TelegramBotDeps["readChannelAllowFromStore"]>;
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<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
readChannelAllowFromStore,
upsertChannelPairingRequest,
};
});
vi.doMock("openclaw/plugin-sdk/conversation-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
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<ReplyPayload | ReplyPayload[] | undefined>
>,
}));
async function dispatchHarnessReplies(
params: DispatchReplyHarnessParams,
runReply: (
params: DispatchReplyHarnessParams,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>,
): Promise<DispatchReplyWithBufferedBlockDispatcherResult> {
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<DispatchReplyWithBufferedBlockDispatcherFn>(
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<string, Set<string>>;
providers: string[];
resolvedDefault: { provider: string; model: string };
} {
const byProvider = new Map<string, Set<string>>();
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<string>();
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 };
}
vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/command-auth")>();
return {
...actual,
listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
buildModelsProviderData,
};
});
vi.doMock("openclaw/plugin-sdk/command-auth.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/command-auth")>();
return {
...actual,
listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
buildModelsProviderData,
};
});
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
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<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
getReplyFromConfig: replySpyHoisted.replySpy,
__replySpy: replySpyHoisted.replySpy,
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher,
};
});
const systemEventsHoisted = vi.hoisted(() => ({
enqueueSystemEventSpy: vi.fn<TelegramBotDeps["enqueueSystemEvent"]>(() => false),
}));
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
systemEventsHoisted.enqueueSystemEventSpy;
vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
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,
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 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<void>) => {
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,
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,
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"],
};
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<string, unknown>) => Promise<void>;
};
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);
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;
});
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);
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;
});