Files
openclaw/extensions/telegram/src/bot-message-dispatch.test-harness.ts
T
Peter SteinbergerandGitHub 06f5f73e47 refactor: own model discovery by runtime lifecycle (#111173)
* refactor(agents): prepare model runtime catalogs

Build lifecycle-owned model and auth snapshots, carry prepared stores into hot agent paths, and serialize config/auth publication.

Credits @zeroaltitude's #90741 investigation and benchmark approach.

* refactor: finish lifecycle-owned model discovery

* fix: align prepared model catalog contracts

* test: align lifecycle catalog mocks

* test: fix prepared catalog type fixtures

* refactor: split prepared model runtime ownership

* fix: import prepared runtime replacement gate type

* test: split media runtime coverage

* test: preserve image auth fixture key types

* test: isolate lifecycle gate fixtures

* chore: keep release changelog owned

* refactor: finish prepared model catalog migration

* test: keep catalog review fixtures scanner-safe

* refactor: preserve lifecycle model runtime ownership

* fix: close prepared runtime lifecycle races

* fix: preserve compaction workspace fallback

* chore: document btw generation rebinding

* fix: preserve prepared generation boundaries

* fix: keep model-list discovery flag explicit

* fix: serialize standalone model runtime activation

* refactor: migrate subagent model catalog lookup

* refactor: clarify doctor catalog lookup seam

* chore: refresh plugin sdk api baseline

* test: migrate swarm catalog dependency

* refactor(telegram): rename runtime catalog seam

* refactor: extract model-aware tool context

* test(models): isolate lifecycle catalog fixtures

* refactor(agents): avoid btw parameter rebinding

* fix(net-policy): align root ipaddr dependency

* fix(build): keep net policy dependency bundled

* fix(deadcode): document net policy compile dependency
2026-07-19 05:30:54 -07:00

726 lines
28 KiB
TypeScript

import { expectDefined } from "@openclaw/normalization-core";
// Telegram tests cover bot message dispatch plugin behavior.
import type { Bot } from "grammy";
import {
createPluginStateKeyedStoreForTests,
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label-config.js";
import type { TelegramBotDeps } from "./bot-deps.js";
import {
createSequencedTestDraftStream,
createTestDraftStream,
} from "./draft-stream.test-helpers.js";
import { setTelegramRuntime } from "./runtime.js";
import {
clearTelegramRuntimeForTest as clearTelegramRuntime,
resetTelegramReplyFenceForTest as resetTelegramReplyFenceForTests,
} from "./runtime.test-support.js";
import type { TelegramRuntime } from "./runtime.types.js";
export type DispatchReplyWithBufferedBlockDispatcherArgs = Parameters<
TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
export function requireInvocationOrder(
mock: { mock: { invocationCallOrder: number[] } },
index: number,
context: string,
): number {
return expectDefined(mock.mock.invocationCallOrder[index], context);
}
const createTelegramDraftStreamHoisted = vi.hoisted(() => vi.fn());
const dispatchReplyWithBufferedBlockDispatcherHoisted = vi.hoisted(() =>
vi.fn<(params: DispatchReplyWithBufferedBlockDispatcherArgs) => Promise<unknown>>(),
);
const deliverRepliesHoisted = vi.hoisted(() => vi.fn());
const deliverInboundReplyWithMessageSendContextHoisted = vi.hoisted(() => vi.fn());
const emitInternalMessageSentHookHoisted = vi.hoisted(() => vi.fn());
const recordOutboundMessageForPromptContextHoisted = vi.hoisted(() => vi.fn());
const createForumTopicTelegramHoisted = vi.hoisted(() => vi.fn());
const deleteMessageTelegramHoisted = vi.hoisted(() => vi.fn());
const editForumTopicTelegramHoisted = vi.hoisted(() => vi.fn());
const editMessageTelegramHoisted = vi.hoisted(() => vi.fn());
const reactMessageTelegramHoisted = vi.hoisted(() => vi.fn());
const sendMessageTelegramHoisted = vi.hoisted(() => vi.fn());
const sendPollTelegramHoisted = vi.hoisted(() => vi.fn());
const sendStickerTelegramHoisted = vi.hoisted(() => vi.fn());
const loadConfigHoisted = vi.hoisted(() => vi.fn(() => ({})));
const readChannelAllowFromStoreHoisted = vi.hoisted(() => vi.fn(async () => []));
const upsertChannelPairingRequestHoisted = vi.hoisted(() =>
vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})),
);
const enqueueSystemEventHoisted = vi.hoisted(() => vi.fn());
const buildModelsProviderDataHoisted = vi.hoisted(() =>
vi.fn(async () => ({
byProvider: new Map<string, Set<string>>(),
providers: [],
resolvedDefault: { provider: "openai", model: "gpt-test" },
modelNames: new Map<string, string>(),
})),
);
const listSkillCommandsForAgentsHoisted = vi.hoisted(() => vi.fn(() => []));
const createChannelMessageReplyPipelineHoisted = vi.hoisted(() =>
vi.fn(() => ({
responsePrefix: undefined,
responsePrefixContextProvider: () => ({ identityName: undefined }),
resolveResponsePrefix: () => undefined,
onModelSelected: () => undefined,
})),
);
const wasSentByBotHoisted = vi.hoisted(() => vi.fn(() => false));
const appendAssistantMirrorMessageByIdentityHoisted = vi.hoisted(() =>
vi.fn<
(
params?: unknown,
) => Promise<
| { ok: true; messageId: string }
| { ok: false; reason: string; code?: "blocked" | "session-rebound" }
>
>(async () => ({
ok: true,
messageId: "m1",
})),
);
const getSessionEntryHoisted = vi.hoisted(() => vi.fn());
const loadSessionStoreHoisted = vi.hoisted(() => vi.fn());
const readLatestAssistantTextByIdentityHoisted = vi.hoisted(() =>
vi.fn<() => Promise<{ id?: string; text: string; timestamp?: number } | undefined>>(
async () => undefined,
),
);
const resolveStorePathHoisted = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
const generateTopicLabelHoisted = vi.hoisted(() => vi.fn());
const describeStickerImageHoisted = vi.hoisted(() =>
vi.fn(async (): Promise<string | null> => null),
);
const loadModelCatalogHoisted = vi.hoisted(() => vi.fn(async () => ({})));
const findModelInCatalogHoisted = vi.hoisted(() => vi.fn(() => null));
const modelSupportsVisionHoisted = vi.hoisted(() => vi.fn(() => false));
const resolveAgentDirHoisted = vi.hoisted(() => vi.fn(() => "/tmp/agent"));
const resolveAgentWorkspaceDirHoisted = vi.hoisted(() => vi.fn(() => "/tmp/workspace"));
const resolveDefaultModelForAgentHoisted = vi.hoisted(() =>
vi.fn(() => ({ provider: "openai", model: "gpt-test" })),
);
const getAgentScopedMediaLocalRootsHoisted = vi.hoisted(() =>
vi.fn((_cfg: unknown, agentId: string) => [`/tmp/.openclaw/workspace-${agentId}`]),
);
const resolveChunkModeHoisted = vi.hoisted(() => vi.fn(() => undefined));
const resolveMarkdownTableModeHoisted = vi.hoisted(() => vi.fn(() => "preserve"));
export const createTelegramDraftStream = createTelegramDraftStreamHoisted;
export const dispatchReplyWithBufferedBlockDispatcher =
dispatchReplyWithBufferedBlockDispatcherHoisted;
export const deliverReplies = deliverRepliesHoisted;
export const deliverInboundReplyWithMessageSendContext =
deliverInboundReplyWithMessageSendContextHoisted;
export const emitInternalMessageSentHook = emitInternalMessageSentHookHoisted;
export const recordOutboundMessageForPromptContext = recordOutboundMessageForPromptContextHoisted;
const createForumTopicTelegram = createForumTopicTelegramHoisted;
const deleteMessageTelegram = deleteMessageTelegramHoisted;
const editForumTopicTelegram = editForumTopicTelegramHoisted;
export const editMessageTelegram = editMessageTelegramHoisted;
const reactMessageTelegram = reactMessageTelegramHoisted;
export const sendMessageTelegram = sendMessageTelegramHoisted;
const sendPollTelegram = sendPollTelegramHoisted;
const sendStickerTelegram = sendStickerTelegramHoisted;
const loadConfig = loadConfigHoisted;
const readChannelAllowFromStore = readChannelAllowFromStoreHoisted;
const upsertChannelPairingRequest = upsertChannelPairingRequestHoisted;
const enqueueSystemEvent = enqueueSystemEventHoisted;
const buildModelsProviderData = buildModelsProviderDataHoisted;
const listSkillCommandsForAgents = listSkillCommandsForAgentsHoisted;
export const createChannelMessageReplyPipeline = createChannelMessageReplyPipelineHoisted;
const wasSentByBot = wasSentByBotHoisted;
export const appendAssistantMirrorMessageByIdentity = appendAssistantMirrorMessageByIdentityHoisted;
const getSessionEntry = getSessionEntryHoisted;
export const loadSessionStore = loadSessionStoreHoisted;
export const readLatestAssistantTextByIdentity = readLatestAssistantTextByIdentityHoisted;
const resolveStorePath = resolveStorePathHoisted;
export const generateTopicLabel = generateTopicLabelHoisted;
export const describeStickerImage = describeStickerImageHoisted;
const loadModelCatalog = loadModelCatalogHoisted;
const findModelInCatalog = findModelInCatalogHoisted;
const modelSupportsVision = modelSupportsVisionHoisted;
const resolveAgentDir = resolveAgentDirHoisted;
const resolveDefaultModelForAgent = resolveDefaultModelForAgentHoisted;
const getAgentScopedMediaLocalRoots = getAgentScopedMediaLocalRootsHoisted;
const resolveChunkMode = resolveChunkModeHoisted;
export const resolveMarkdownTableMode = resolveMarkdownTableModeHoisted;
vi.mock("./draft-stream.js", () => ({
createTelegramDraftStream: createTelegramDraftStreamHoisted,
}));
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
return {
...actual,
deliverInboundReplyWithMessageSendContext: deliverInboundReplyWithMessageSendContextHoisted,
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
type TestTurn = {
storePath: string;
recordInboundSession: Parameters<
typeof actual.runPreparedInboundReply
>[0]["recordInboundSession"];
};
return {
...actual,
runChannelInboundEvent: async (params: RunParams) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
}
const eventClass = (await params.adapter.classify?.(input)) ?? {
kind: "message" as const,
canStartAgentTurn: true,
};
const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {};
const resolved = await params.adapter.resolveTurn(
input,
eventClass,
"kind" in preflight ? { admission: preflight } : preflight,
);
if (!("route" in resolved) || !("delivery" in resolved)) {
throw new Error("expected assembled Telegram channel turn plan");
}
const testTurn = (params.raw as { turn: TestTurn }).turn;
const result = await actual.runPreparedInboundReply({
channel: resolved.channel,
accountId: resolved.accountId,
routeSessionKey: resolved.route.sessionKey,
storePath: testTurn.storePath,
ctxPayload: resolved.ctxPayload,
recordInboundSession: testTurn.recordInboundSession,
afterRecord: resolved.afterRecord,
record: resolved.record,
history: resolved.history,
admission: resolved.admission,
botLoopProtection: resolved.botLoopProtection,
runDispatch: async () =>
await dispatchReplyWithBufferedBlockDispatcherHoisted({
ctx: resolved.ctxPayload,
cfg: resolved.cfg,
dispatcherOptions: {
...resolved.dispatcherOptions,
deliver: resolved.delivery.deliver,
onError: resolved.delivery.onError,
},
toolsAllow: resolved.toolsAllow,
replyOptions: resolved.replyOptions,
replyResolver: resolved.replyResolver,
}),
});
await params.adapter.onFinalize?.(result);
return result;
},
};
});
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
return {
...actual,
appendAssistantMirrorMessageByIdentity: appendAssistantMirrorMessageByIdentityHoisted,
readLatestAssistantTextByIdentity: readLatestAssistantTextByIdentityHoisted,
};
});
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliverRepliesHoisted,
emitInternalMessageSentHook: emitInternalMessageSentHookHoisted,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies: deliverRepliesHoisted,
emitInternalMessageSentHook: emitInternalMessageSentHookHoisted,
}));
vi.mock("./send.js", () => ({
createForumTopicTelegram: createForumTopicTelegramHoisted,
deleteMessageTelegram: deleteMessageTelegramHoisted,
editForumTopicTelegram: editForumTopicTelegramHoisted,
editMessageTelegram: editMessageTelegramHoisted,
reactMessageTelegram: reactMessageTelegramHoisted,
sendMessageTelegram: sendMessageTelegramHoisted,
sendPollTelegram: sendPollTelegramHoisted,
sendStickerTelegram: sendStickerTelegramHoisted,
}));
vi.mock("./bot-message-dispatch.runtime.js", () => ({
generateTopicLabel: generateTopicLabelHoisted,
getSessionEntry: getSessionEntryHoisted,
getAgentScopedMediaLocalRoots: getAgentScopedMediaLocalRootsHoisted,
resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime,
resolveChunkMode: resolveChunkModeHoisted,
resolveMarkdownTableMode: resolveMarkdownTableModeHoisted,
resolveStorePath: resolveStorePathHoisted,
}));
vi.mock("./bot-message-dispatch.agent.runtime.js", () => ({
findModelInCatalog: findModelInCatalogHoisted,
loadPreparedModelCatalog: loadModelCatalogHoisted,
modelSupportsVision: modelSupportsVisionHoisted,
resolveAgentDir: resolveAgentDirHoisted,
resolveAgentWorkspaceDir: resolveAgentWorkspaceDirHoisted,
resolveDefaultModelForAgent: resolveDefaultModelForAgentHoisted,
}));
vi.mock("./sticker-cache.js", () => ({
cacheSticker: vi.fn(),
getCachedSticker: () => null,
getCacheStats: () => ({ count: 0 }),
searchStickers: () => [],
getAllCachedStickers: () => [],
describeStickerImage: describeStickerImageHoisted,
}));
export let dispatchTelegramMessage: typeof import("./bot-message-dispatch.js").dispatchTelegramMessage;
function installTelegramStateRuntimeForTest(): void {
setTelegramRuntime({
state: {
openKeyedStore: ((options) =>
createPluginStateKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openKeyedStore"],
openSyncKeyedStore: ((options) =>
createPluginStateSyncKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openSyncKeyedStore"],
},
channel: {},
} as TelegramRuntime);
}
export const telegramDepsForTest: TelegramBotDeps = {
getRuntimeConfig: loadConfig as TelegramBotDeps["getRuntimeConfig"],
resolveStorePath: resolveStorePath as TelegramBotDeps["resolveStorePath"],
getSessionEntry: getSessionEntry as TelegramBotDeps["getSessionEntry"],
readChannelAllowFromStore:
readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"],
upsertChannelPairingRequest:
upsertChannelPairingRequest as TelegramBotDeps["upsertChannelPairingRequest"],
enqueueSystemEvent: enqueueSystemEvent as TelegramBotDeps["enqueueSystemEvent"],
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyWithBufferedBlockDispatcher as TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"],
buildModelsProviderData: buildModelsProviderData as TelegramBotDeps["buildModelsProviderData"],
listSkillCommandsForAgents:
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
createChannelMessageReplyPipeline:
createChannelMessageReplyPipeline as TelegramBotDeps["createChannelMessageReplyPipeline"],
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
createTelegramDraftStream:
createTelegramDraftStream as TelegramBotDeps["createTelegramDraftStream"],
deliverReplies: deliverReplies as TelegramBotDeps["deliverReplies"],
deliverInboundReplyWithMessageSendContext:
deliverInboundReplyWithMessageSendContext as TelegramBotDeps["deliverInboundReplyWithMessageSendContext"],
emitInternalMessageSentHook:
emitInternalMessageSentHook as TelegramBotDeps["emitInternalMessageSentHook"],
editMessageTelegram: editMessageTelegram as TelegramBotDeps["editMessageTelegram"],
recordOutboundMessageForPromptContext:
recordOutboundMessageForPromptContext as TelegramBotDeps["recordOutboundMessageForPromptContext"],
};
export type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"];
export const trailingFinalStatusText = "Post-final plugin status";
async function loadTelegramDispatchForTests() {
({ dispatchTelegramMessage } = await import("./bot-message-dispatch.js"));
}
function resetTelegramDispatchTestState() {
resetPluginStateStoreForTests({ closeDatabase: false });
installTelegramStateRuntimeForTest();
resetTelegramReplyFenceForTests();
createTelegramDraftStream.mockReset();
dispatchReplyWithBufferedBlockDispatcher.mockReset();
deliverReplies.mockReset();
deliverInboundReplyWithMessageSendContext.mockReset();
emitInternalMessageSentHook.mockReset();
recordOutboundMessageForPromptContext.mockReset();
createForumTopicTelegram.mockReset();
deleteMessageTelegram.mockReset();
editForumTopicTelegram.mockReset();
editMessageTelegram.mockReset();
reactMessageTelegram.mockReset();
sendMessageTelegram.mockReset();
sendPollTelegram.mockReset();
sendStickerTelegram.mockReset();
loadConfig.mockReset();
readChannelAllowFromStore.mockReset();
upsertChannelPairingRequest.mockReset();
enqueueSystemEvent.mockReset();
buildModelsProviderData.mockReset();
listSkillCommandsForAgents.mockReset();
createChannelMessageReplyPipeline.mockReset();
wasSentByBot.mockReset();
appendAssistantMirrorMessageByIdentity.mockReset();
readLatestAssistantTextByIdentity.mockReset();
getSessionEntry.mockReset();
loadSessionStore.mockReset();
resolveStorePath.mockReset();
generateTopicLabel.mockReset();
getAgentScopedMediaLocalRoots.mockClear();
resolveChunkMode.mockClear();
resolveMarkdownTableMode.mockClear();
describeStickerImage.mockReset();
loadModelCatalog.mockReset();
findModelInCatalog.mockReset();
modelSupportsVision.mockReset();
resolveAgentDir.mockReset();
resolveDefaultModelForAgent.mockReset();
loadConfig.mockReturnValue({});
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
});
deliverReplies.mockResolvedValue({ delivered: true });
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
status: "unsupported",
reason: "missing_outbound_handler",
});
emitInternalMessageSentHook.mockResolvedValue(undefined);
recordOutboundMessageForPromptContext.mockResolvedValue(true);
createForumTopicTelegram.mockResolvedValue({ message_thread_id: 777 });
deleteMessageTelegram.mockResolvedValue(true);
editForumTopicTelegram.mockResolvedValue(true);
editMessageTelegram.mockResolvedValue({ ok: true });
reactMessageTelegram.mockResolvedValue(true);
sendMessageTelegram.mockResolvedValue({ message_id: 1001 });
sendPollTelegram.mockResolvedValue({ message_id: 1001 });
sendStickerTelegram.mockResolvedValue({ message_id: 1001 });
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({
code: "PAIRCODE",
created: true,
});
enqueueSystemEvent.mockResolvedValue(undefined);
buildModelsProviderData.mockResolvedValue({
byProvider: new Map<string, Set<string>>(),
providers: [],
resolvedDefault: { provider: "openai", model: "gpt-test" },
modelNames: new Map<string, string>(),
});
listSkillCommandsForAgents.mockReturnValue([]);
createChannelMessageReplyPipeline.mockReturnValue({
responsePrefix: undefined,
responsePrefixContextProvider: () => ({ identityName: undefined }),
resolveResponsePrefix: () => undefined,
onModelSelected: () => undefined,
});
wasSentByBot.mockReturnValue(false);
resolveStorePath.mockReturnValue("/tmp/sessions.json");
readLatestAssistantTextByIdentity.mockResolvedValue(undefined);
appendAssistantMirrorMessageByIdentity.mockResolvedValue({
ok: true,
messageId: "m1",
});
loadSessionStore.mockReturnValue({});
getSessionEntry.mockImplementation(
({ sessionKey }: { sessionKey: string }) =>
(loadSessionStore() as Record<string, unknown>)[sessionKey],
);
generateTopicLabel.mockResolvedValue("Topic label");
describeStickerImage.mockResolvedValue(null);
loadModelCatalog.mockResolvedValue({});
findModelInCatalog.mockReturnValue(null);
modelSupportsVision.mockReturnValue(false);
resolveAgentDir.mockReturnValue("/tmp/agent");
resolveDefaultModelForAgent.mockReturnValue({
provider: "openai",
model: "gpt-test",
});
}
function cleanupTelegramDispatchTestState() {
clearTelegramRuntime();
resetPluginStateStoreForTests();
}
export const createDraftStream = (messageId?: number) => createTestDraftStream({ messageId });
export const createSequencedDraftStream = (startMessageId = 1001) =>
createSequencedTestDraftStream(startMessageId);
export function setupDraftStreams(params?: {
answerMessageId?: number;
reasoningMessageId?: number;
}) {
const answerDraftStream = createDraftStream(params?.answerMessageId);
const reasoningDraftStream = createDraftStream(params?.reasoningMessageId);
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
return { answerDraftStream, reasoningDraftStream };
}
export function mockDefaultSessionEntry(entry: Record<string, unknown> = { sessionId: "s1" }) {
loadSessionStore.mockReturnValue({
"agent:default:telegram:direct:123": {
updatedAt: 1,
...entry,
},
});
}
export function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
if (!record || typeof record !== "object") {
throw new Error("Expected record");
}
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
export function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0) {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`Expected mock call ${callIndex}`);
}
return call[argIndex];
}
export function expectDraftStreamParams(expected: Record<string, unknown>) {
return expectRecordFields(mockCallArg(createTelegramDraftStream), expected);
}
export function telegramProgressPreview(_plainText: string, html: string) {
return {
text: html.replaceAll("\n", "<br>"),
parseMode: "HTML" as const,
};
}
export function expectDeliverRepliesParams(expected: Record<string, unknown>, callIndex = 0) {
return expectRecordFields(mockCallArg(deliverReplies, callIndex), expected);
}
export function expectDeliveredReply(
index: number,
expected: Record<string, unknown>,
callIndex = 0,
) {
const params = expectDeliverRepliesParams({}, callIndex);
const replies = params.replies as Array<unknown> | undefined;
if (!Array.isArray(replies)) {
throw new Error("Expected delivered replies array");
}
return expectRecordFields(replies[index], expected);
}
export function allDeliveredReplyTexts(): string[] {
return deliverReplies.mock.calls.flatMap((call: unknown[]) =>
((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map(
(reply) => reply.text ?? "",
),
);
}
export function expectDispatchParams(expected: Record<string, unknown>) {
return expectRecordFields(mockCallArg(dispatchReplyWithBufferedBlockDispatcher), expected);
}
// The collapse bar edits the live window message in place (finalizeToPreview)
// instead of deleting it and reposting the bar as a new message.
export function expectWindowCollapsedTo(
stream: { finalizeToPreview: { mock: { calls: unknown[][] } } },
barText: string,
) {
const calls = stream.finalizeToPreview.mock.calls;
expect(calls.length).toBeGreaterThan(0);
const preview = calls.at(-1)?.[0] as { text?: string } | undefined;
expect(preview?.text).toBe(barText);
}
export function createContext(overrides?: Partial<TelegramMessageContext>): TelegramMessageContext {
const base = {
ctxPayload: {},
primaryCtx: { message: { chat: { id: 123, type: "private" } } },
msg: {
chat: { id: 123, type: "private" },
message_id: 456,
message_thread_id: 777,
},
chatId: 123,
isGroup: false,
groupConfig: undefined,
resolvedThreadId: undefined,
replyThreadId: 777,
threadSpec: { id: 777, scope: "dm" },
historyKey: undefined,
historyLimit: 0,
groupHistories: new Map(),
route: { agentId: "default", accountId: "default" },
skillFilter: undefined,
sendTyping: vi.fn(),
sendRecordVoice: vi.fn(),
sendChatActionHandler: { sendChatAction: vi.fn(async () => undefined) },
ackReactionPromise: null,
reactionApi: null,
removeAckAfterReply: false,
} as unknown as TelegramMessageContext;
base.turn = {
storePath: "/tmp/openclaw/telegram-sessions.json",
recordInboundSession: vi.fn(async () => undefined),
record: {
onRecordError: vi.fn(),
},
} as unknown as TelegramMessageContext["turn"];
return {
...base,
...overrides,
// Merge nested fields when overrides provide partial objects.
primaryCtx: {
...(base.primaryCtx as object),
...(overrides?.primaryCtx ? (overrides.primaryCtx as object) : null),
} as TelegramMessageContext["primaryCtx"],
msg: {
...(base.msg as object),
...(overrides?.msg ? (overrides.msg as object) : null),
} as TelegramMessageContext["msg"],
route: {
...(base.route as object),
...(overrides?.route ? (overrides.route as object) : null),
} as TelegramMessageContext["route"],
};
}
export function createStatusReactionController() {
return {
setQueued: vi.fn(),
setThinking: vi.fn(async () => {}),
setTool: vi.fn(async () => {}),
setCompacting: vi.fn(async () => {}),
cancelPending: vi.fn(),
setError: vi.fn(async () => {}),
setDone: vi.fn(async () => {}),
restoreInitial: vi.fn(async () => {}),
};
}
export function createDirectSessionPayload(): TelegramMessageContext["ctxPayload"] {
return {
SessionKey: "agent:test:telegram:direct:123",
ChatType: "direct",
} as TelegramMessageContext["ctxPayload"];
}
export function createBot(): Bot {
return {
api: {
sendMessage: vi.fn(async (_chatId, _text, params) => ({
message_id: typeof params?.message_thread_id === "number" ? params.message_thread_id : 1001,
})),
editMessageText: vi.fn(async () => ({ message_id: 1001 })),
deleteMessage: vi.fn().mockResolvedValue(true),
editForumTopic: vi.fn().mockResolvedValue(true),
},
} as unknown as Bot;
}
export function createRuntime(): Parameters<typeof dispatchTelegramMessage>[0]["runtime"] {
return {
log: vi.fn(),
error: vi.fn(),
exit: () => {
throw new Error("exit");
},
};
}
export async function dispatchWithContext(params: {
context: TelegramMessageContext;
cfg?: Parameters<typeof dispatchTelegramMessage>[0]["cfg"];
telegramCfg?: Parameters<typeof dispatchTelegramMessage>[0]["telegramCfg"];
streamMode?: Parameters<typeof dispatchTelegramMessage>[0]["streamMode"];
telegramDeps?: TelegramBotDeps;
bot?: Bot;
replyToMode?: Parameters<typeof dispatchTelegramMessage>[0]["replyToMode"];
retryDispatchErrors?: boolean;
suppressFailureFallback?: boolean;
textLimit?: number;
turnAdoptionLifecycle?: Parameters<typeof dispatchTelegramMessage>[0]["turnAdoptionLifecycle"];
runtime?: Parameters<typeof dispatchTelegramMessage>[0]["runtime"];
}) {
const bot = params.bot ?? createBot();
return await dispatchTelegramMessage({
context: params.context,
bot,
cfg: params.cfg ?? {},
runtime: params.runtime ?? createRuntime(),
replyToMode: params.replyToMode ?? "first",
streamMode: params.streamMode ?? "partial",
textLimit: params.textLimit ?? 4096,
telegramCfg: params.telegramCfg ?? {},
telegramDeps: params.telegramDeps ?? telegramDepsForTest,
opts: { token: "token" },
retryDispatchErrors: params.retryDispatchErrors,
suppressFailureFallback: params.suppressFailureFallback,
turnAdoptionLifecycle: params.turnAdoptionLifecycle,
});
}
export function createReasoningStreamContext(): TelegramMessageContext {
loadSessionStore.mockReturnValue({
s1: { reasoningLevel: "stream" },
});
return createContext({
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
});
}
export function createReasoningDefaultContext(): TelegramMessageContext {
loadSessionStore.mockReturnValue({
s1: {},
});
return createContext({
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
route: { agentId: "ops" } as unknown as TelegramMessageContext["route"],
});
}
export function createReasoningForumTopicContext(): TelegramMessageContext {
loadSessionStore.mockReturnValue({
s1: { reasoningLevel: "stream" },
});
return createContext({
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
msg: {
chat: { id: -100123, type: "supergroup", is_forum: true },
message_id: 456,
message_thread_id: 88,
} as unknown as TelegramMessageContext["msg"],
chatId: -100123,
isGroup: true,
threadSpec: { id: 88, scope: "forum" },
});
}
export function describeTelegramDispatch(name: string, registerTests: () => void): void {
describe(name, () => {
beforeAll(loadTelegramDispatchForTests);
beforeEach(resetTelegramDispatchTestState);
afterEach(cleanupTelegramDispatchTestState);
registerTests();
});
}
export type { TelegramBotDeps };