mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 12:41:42 +00:00
* fix(gateway): approval registry hardening and protocol-surface follow-ups
Follow-up delta to the merged #103579 head, rebased onto current main:
- gateway-protocol wire types derive from owner-module schema consts
(types.ts tombstone) and ProtocolSchemas leaves the package index so the
public plugin-sdk d.ts graph tree-shakes the registry declaration
- approval access authority follows the operator.approvals scope tier with
reviewerDeviceIds as the opt-in restriction (cross-surface
first-answer-wins; requester identity gates only legacy adapters)
- plugin node.invoke approvals register directly so unrenderable
presentations fail closed before request routing
- exec-approval manager reconciliation with #103515 revocation hardening
(resolution source attribution, one-shot ask-fallback consumption)
- surface-report pins and plugin-sdk API baseline refreshed; Swift models
regenerated
* feat(channels): add typed operator approval actions
Squash-rebased #103679 segment onto the durable-approval-registry tip on
current main. Typed approval/command/select presentation actions replace
raw-string inference across slack/telegram/discord/matrix/imessage/whatsapp,
approval.resolve carries an explicit kind, and channel adapters map native
callback envelopes through the typed action registry.
Drift reconciliation: deprecated buildExecApprovalInteractiveReply assertions
dropped (#104650 removed the shims); worker_environments bootstrap-column
migration kept alongside the approval resolution_ref backfill; plugin-sdk API
baseline regenerated.
(cherry picked from commit 68765a5d39d2118c88a7a54d00387337912d4494)
(cherry picked from commit 8642ac12af142e4b751f4f30d4b114615e7e5f66)
(cherry picked from commit 036c4bc39499925fc03de16ec9302e346769350a)
(cherry picked from commit 19dc350d6bc34e29a5169c6bc80971b0ad12adde)
(cherry picked from commit fc978b0bad86aef421c79f6a211b25cc1b743c01)
(cherry picked from commit 10de4d1ed5071f9be6ad1ee5d1e32c0fa8c9d11c)
(cherry picked from commit 9a664ced1b1fa740172b258f355f1a82925ae41c)
(cherry picked from commit c5ff69abbf444139e9e007bfa45beb0f00ffea54)
(cherry picked from commit d466a80795)
(cherry picked from commit f5b4fe40dd5c961322f8553cc80b2fdfb3f6503e)
(cherry picked from commit 7340b4749a4cc4c72f7a41cce1bc9cb550cae038)
(cherry picked from commit a151f41808f23ae60b10305ccd2bc959b9169a86)
* fix(approvals): preserve typed transport ownership
* test(imessage): narrow chunked approval text
* refactor(protocol): remove retired type tombstone
* fix(plugin-sdk): align surface budgets after rebase
* docs(changelog): note typed operator approvals
* docs(changelog): defer typed approval release note
721 lines
27 KiB
TypeScript
721 lines
27 KiB
TypeScript
// Telegram plugin module implements bot.create telegram bot harness behavior.
|
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { beforeEach, vi } from "vitest";
|
|
import type { TelegramBotDeps } from "./bot-deps.js";
|
|
|
|
type AnyMock = ReturnType<typeof vi.fn>;
|
|
type AnyAsyncMock = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
|
|
type GetRuntimeConfigFn =
|
|
typeof import("openclaw/plugin-sdk/runtime-config-snapshot").getRuntimeConfig;
|
|
type GetSessionEntryFn = typeof import("openclaw/plugin-sdk/session-store-runtime").getSessionEntry;
|
|
type ListSessionEntriesFn =
|
|
typeof import("openclaw/plugin-sdk/session-store-runtime").listSessionEntries;
|
|
type ResolveStorePathFn =
|
|
typeof import("openclaw/plugin-sdk/session-store-runtime").resolveStorePath;
|
|
type ReadSessionUpdatedAtFn =
|
|
typeof import("openclaw/plugin-sdk/session-store-runtime").readSessionUpdatedAt;
|
|
type SessionEntry = import("openclaw/plugin-sdk/session-store-runtime").SessionEntry;
|
|
type SessionStore = Record<string, SessionEntry>;
|
|
type LoadSessionStoreFn = (storePath?: string, opts?: unknown) => SessionStore;
|
|
type TelegramBotRuntimeForTest = NonNullable<
|
|
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
|
|
>;
|
|
type ResolveTelegramApprovalForTest = NonNullable<TelegramBotDeps["resolveApproval"]>;
|
|
type DispatchReplyWithBufferedBlockDispatcherFn =
|
|
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
|
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
|
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
|
>;
|
|
type DispatchReplyHarnessParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
|
type ReplyPayloadLike = {
|
|
text?: string;
|
|
mediaUrl?: string;
|
|
mediaUrls?: string[];
|
|
replyToId?: string;
|
|
};
|
|
|
|
const { sessionStorePath } = vi.hoisted(() => {
|
|
const tempRoot =
|
|
process.platform === "win32"
|
|
? (process.env.TEMP ?? process.env.TMP ?? "C:\\Windows\\Temp")
|
|
: (process.env.TMPDIR ?? "/tmp");
|
|
const separator = process.platform === "win32" ? "\\" : "/";
|
|
return {
|
|
sessionStorePath: `${tempRoot.replace(/[\\/]+$/u, "")}${separator}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,
|
|
}));
|
|
|
|
const {
|
|
getSessionEntryMock,
|
|
getRuntimeConfig,
|
|
listSessionEntriesMock,
|
|
loadSessionStoreMock,
|
|
readSessionUpdatedAtMock,
|
|
recordInboundSessionMock,
|
|
resolveStorePathMock,
|
|
sessionStoreEntries,
|
|
} = vi.hoisted(
|
|
(): {
|
|
getSessionEntryMock: MockFn<GetSessionEntryFn>;
|
|
getRuntimeConfig: MockFn<GetRuntimeConfigFn>;
|
|
listSessionEntriesMock: MockFn<ListSessionEntriesFn>;
|
|
loadSessionStoreMock: MockFn<LoadSessionStoreFn>;
|
|
readSessionUpdatedAtMock: MockFn<ReadSessionUpdatedAtFn>;
|
|
recordInboundSessionMock: MockFn<NonNullable<TelegramBotDeps["recordInboundSession"]>>;
|
|
resolveStorePathMock: MockFn<ResolveStorePathFn>;
|
|
sessionStoreEntries: { value: SessionStore };
|
|
} => ({
|
|
getRuntimeConfig: vi.fn<GetRuntimeConfigFn>(() => ({})),
|
|
resolveStorePathMock: vi.fn<ResolveStorePathFn>(
|
|
(storePath?: string) => storePath ?? sessionStorePath,
|
|
),
|
|
loadSessionStoreMock: vi.fn<LoadSessionStoreFn>(
|
|
(_storePath, _opts) => sessionStoreEntries.value,
|
|
),
|
|
getSessionEntryMock: vi.fn<GetSessionEntryFn>(({ storePath, sessionKey, agentId }) => {
|
|
const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId });
|
|
return loadSessionStoreMock(resolvedStorePath)[sessionKey];
|
|
}),
|
|
listSessionEntriesMock: vi.fn<ListSessionEntriesFn>(({ storePath, agentId } = {}) => {
|
|
const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId });
|
|
return Object.entries(loadSessionStoreMock(resolvedStorePath)).map(([sessionKey, entry]) => ({
|
|
sessionKey,
|
|
entry,
|
|
}));
|
|
}),
|
|
readSessionUpdatedAtMock: vi.fn<ReadSessionUpdatedAtFn>(() => undefined),
|
|
recordInboundSessionMock: vi.fn(async () => undefined),
|
|
sessionStoreEntries: { value: {} as SessionStore },
|
|
}),
|
|
);
|
|
|
|
export function getLoadConfigMock(): AnyMock {
|
|
return getRuntimeConfig;
|
|
}
|
|
|
|
export function getLoadSessionStoreMock(): AnyMock {
|
|
return loadSessionStoreMock;
|
|
}
|
|
|
|
export function setSessionStoreEntriesForTest(entries: SessionStore) {
|
|
sessionStoreEntries.value = structuredClone(entries);
|
|
}
|
|
|
|
const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
|
|
(): {
|
|
readChannelAllowFromStore: MockFn<TelegramBotDeps["readChannelAllowFromStore"]>;
|
|
upsertChannelPairingRequest: MockFn<TelegramBotDeps["upsertChannelPairingRequest"]>;
|
|
} => ({
|
|
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
|
|
upsertChannelPairingRequest: vi.fn(async () => ({
|
|
code: "PAIRCODE",
|
|
created: true,
|
|
})),
|
|
}),
|
|
);
|
|
|
|
export function getReadChannelAllowFromStoreMock(): MockFn<
|
|
TelegramBotDeps["readChannelAllowFromStore"]
|
|
> {
|
|
return readChannelAllowFromStore;
|
|
}
|
|
|
|
export function getUpsertChannelPairingRequestMock(): MockFn<
|
|
TelegramBotDeps["upsertChannelPairingRequest"]
|
|
> {
|
|
return upsertChannelPairingRequest;
|
|
}
|
|
|
|
const skillCommandListHoisted = vi.hoisted(() => ({
|
|
listSkillCommandsForAgents: vi.fn(() => []),
|
|
}));
|
|
const modelProviderDataHoisted = vi.hoisted(
|
|
(): { buildModelsProviderData: MockFn<TelegramBotDeps["buildModelsProviderData"]> } => ({
|
|
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<ReplyPayloadLike | ReplyPayloadLike[] | undefined>
|
|
>,
|
|
}));
|
|
|
|
async function dispatchHarnessReplies(
|
|
params: DispatchReplyHarnessParams,
|
|
runReply: (
|
|
params: DispatchReplyHarnessParams,
|
|
) => Promise<ReplyPayloadLike | ReplyPayloadLike[] | undefined>,
|
|
): Promise<DispatchReplyWithBufferedBlockDispatcherResult> {
|
|
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
|
const reply = await runReply(params);
|
|
const payloads: ReplyPayloadLike[] =
|
|
reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
|
|
let finalCount = 0;
|
|
for (const payload of payloads) {
|
|
const text =
|
|
typeof payload.text === "string" &&
|
|
params.dispatcherOptions.responsePrefix &&
|
|
!payload.text.startsWith(params.dispatcherOptions.responsePrefix)
|
|
? `${params.dispatcherOptions.responsePrefix} ${payload.text}`
|
|
: payload.text;
|
|
const finalPayload = text === payload.text ? payload : { ...payload, text };
|
|
try {
|
|
await params.dispatcherOptions.deliver?.(finalPayload, { kind: "final" });
|
|
finalCount += 1;
|
|
} catch (err) {
|
|
void params.dispatcherOptions.onError?.(err, { kind: "final" });
|
|
}
|
|
}
|
|
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;
|
|
const menuSyncHoisted = vi.hoisted(() => ({
|
|
syncTelegramMenuCommands: vi.fn(async ({ bot, commandsToRegister }) => {
|
|
await bot.api.setMyCommands(commandsToRegister);
|
|
}),
|
|
}));
|
|
export const syncTelegramMenuCommands = menuSyncHoisted.syncTelegramMenuCommands;
|
|
|
|
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 normalizeLowercaseStringOrEmptyForTest(value: string | undefined): string {
|
|
return value?.trim().toLowerCase() ?? "";
|
|
}
|
|
|
|
function resolveDefaultModelForAgentForTest(params: { cfg: OpenClawConfig }): {
|
|
provider: string;
|
|
model: string;
|
|
} {
|
|
const modelConfig = params.cfg.agents?.defaults?.model;
|
|
const rawModel =
|
|
typeof modelConfig === "string" ? modelConfig : (modelConfig?.primary ?? "openai/gpt-5.4");
|
|
const parsed = parseModelRef(rawModel);
|
|
const provider = normalizeLowercaseStringOrEmptyForTest(parsed.provider) || "openai";
|
|
return {
|
|
provider: provider === "bedrock" ? "amazon-bedrock" : provider,
|
|
model: parsed.model || "gpt-5.4",
|
|
};
|
|
}
|
|
|
|
function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
|
|
byProvider: Map<string, Set<string>>;
|
|
providers: string[];
|
|
resolvedDefault: { provider: string; model: string };
|
|
modelNames: Map<string, string>;
|
|
} {
|
|
const byProvider = new Map<string, Set<string>>();
|
|
const add = (providerRaw: string | undefined, modelRaw: string | undefined) => {
|
|
const provider = normalizeLowercaseStringOrEmptyForTest(providerRaw);
|
|
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 = resolveDefaultModelForAgentForTest({ 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<string, string>() };
|
|
}
|
|
|
|
const systemEventsHoisted = vi.hoisted(() => ({
|
|
enqueueSystemEventSpy: vi.fn<TelegramBotDeps["enqueueSystemEvent"]>(() => false),
|
|
}));
|
|
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
|
|
systemEventsHoisted.enqueueSystemEventSpy;
|
|
const execApprovalHoisted = vi.hoisted(
|
|
(): { resolveExecApprovalSpy: MockFn<ResolveTelegramApprovalForTest> } => ({
|
|
resolveExecApprovalSpy: vi.fn<ResolveTelegramApprovalForTest>(async () => ({
|
|
applied: true,
|
|
approval: {
|
|
id: "test-approval",
|
|
urlPath: "/approve/test-approval",
|
|
createdAtMs: 1,
|
|
expiresAtMs: 60_000,
|
|
resolvedAtMs: 2,
|
|
reason: "user",
|
|
status: "allowed",
|
|
decision: "allow-once",
|
|
presentation: {
|
|
kind: "exec",
|
|
commandText: "echo test",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
},
|
|
},
|
|
})),
|
|
}),
|
|
);
|
|
export const resolveExecApprovalSpy: MockFn<ResolveTelegramApprovalForTest> =
|
|
execApprovalHoisted.resolveExecApprovalSpy;
|
|
|
|
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(),
|
|
onSpy: vi.fn(),
|
|
stopSpy: vi.fn(),
|
|
commandSpy: vi.fn(),
|
|
botCtorSpy: vi.fn(
|
|
(_: string, __?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => undefined,
|
|
),
|
|
answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock,
|
|
sendChatActionSpy: vi.fn(),
|
|
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
|
editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
|
|
deleteMessageSpy: 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 }; botInfo?: unknown }) => 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 deleteMessageSpy: AnyAsyncMock = grammySpies.deleteMessageSpy;
|
|
export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy;
|
|
export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy;
|
|
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;
|
|
|
|
type RichMessageParams = {
|
|
chat_id?: string | number;
|
|
message_id?: number;
|
|
rich_message?: {
|
|
markdown?: string;
|
|
html?: string;
|
|
};
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function getRichMessageText(params: RichMessageParams): string {
|
|
return params.rich_message?.markdown ?? params.rich_message?.html ?? "";
|
|
}
|
|
|
|
function toLegacyMessageParams(params: RichMessageParams): Record<string, unknown> {
|
|
const { chat_id: _chatId, message_id: _messageId, rich_message: _richMessage, ...rest } = params;
|
|
const replyParameters = rest.reply_parameters;
|
|
if (
|
|
replyParameters &&
|
|
typeof replyParameters === "object" &&
|
|
!("quote" in replyParameters) &&
|
|
typeof (replyParameters as { message_id?: unknown }).message_id === "number"
|
|
) {
|
|
rest.reply_to_message_id = (replyParameters as { message_id: number }).message_id;
|
|
rest.allow_sending_without_reply = true;
|
|
delete rest.reply_parameters;
|
|
}
|
|
return rest;
|
|
}
|
|
|
|
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,
|
|
deleteMessage: grammySpies.deleteMessageSpy,
|
|
setMessageReaction: grammySpies.setMessageReactionSpy,
|
|
setMyCommands: grammySpies.setMyCommandsSpy,
|
|
getMe: grammySpies.getMeSpy,
|
|
getChat: grammySpies.getChatSpy,
|
|
sendMessage: grammySpies.sendMessageSpy,
|
|
sendAnimation: grammySpies.sendAnimationSpy,
|
|
sendPhoto: grammySpies.sendPhotoSpy,
|
|
getFile: grammySpies.getFileSpy,
|
|
raw: {
|
|
sendRichMessage: async (params: RichMessageParams) =>
|
|
grammySpies.sendMessageSpy(
|
|
params.chat_id,
|
|
getRichMessageText(params),
|
|
toLegacyMessageParams(params),
|
|
),
|
|
editMessageText: async (params: RichMessageParams) =>
|
|
grammySpies.editMessageTextSpy(
|
|
params.chat_id,
|
|
params.message_id,
|
|
getRichMessageText(params),
|
|
toLegacyMessageParams(params),
|
|
),
|
|
},
|
|
};
|
|
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 }; botInfo?: unknown },
|
|
) {
|
|
(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 = {
|
|
getRuntimeConfig,
|
|
getSessionEntry: getSessionEntryMock,
|
|
listSessionEntries: listSessionEntriesMock,
|
|
resolveStorePath: resolveStorePathMock,
|
|
readSessionUpdatedAt: readSessionUpdatedAtMock,
|
|
recordInboundSession: recordInboundSessionMock as TelegramBotDeps["recordInboundSession"],
|
|
recordChannelActivity: vi.fn() as TelegramBotDeps["recordChannelActivity"],
|
|
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
|
|
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
|
|
resolvePinnedMainDmOwnerFromAllowlist: () => null,
|
|
buildChannelInboundEventContext,
|
|
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"],
|
|
syncTelegramMenuCommands: syncTelegramMenuCommands as TelegramBotDeps["syncTelegramMenuCommands"],
|
|
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
|
|
resolveApproval: resolveExecApprovalSpy,
|
|
resolveLegacyApproval: async (params) => {
|
|
await resolveExecApprovalSpy(params);
|
|
},
|
|
};
|
|
|
|
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: ["*"] },
|
|
},
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
function clearTelegramDispatchDedupeFilesForTest(): void {
|
|
const dir = path.dirname(sessionStorePath);
|
|
if (!existsSync(dir)) {
|
|
return;
|
|
}
|
|
const prefix = `${path.basename(sessionStorePath)}.telegram-message-dispatch-`;
|
|
for (const entry of readdirSync(dir)) {
|
|
if (entry.startsWith(prefix)) {
|
|
rmSync(path.join(dir, entry), { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
getRuntimeConfig.mockReset();
|
|
getRuntimeConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
|
|
sessionStoreEntries.value = {};
|
|
rmSync(`${sessionStorePath}.telegram-messages.json`, { force: true });
|
|
clearTelegramDispatchDedupeFilesForTest();
|
|
loadSessionStoreMock.mockReset();
|
|
loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value);
|
|
resolveStorePathMock.mockReset();
|
|
resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath);
|
|
getSessionEntryMock.mockReset();
|
|
getSessionEntryMock.mockImplementation(({ storePath, sessionKey, agentId }) => {
|
|
const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId });
|
|
return loadSessionStoreMock(resolvedStorePath)[sessionKey];
|
|
});
|
|
listSessionEntriesMock.mockReset();
|
|
listSessionEntriesMock.mockImplementation(({ storePath, agentId } = {}) => {
|
|
const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId });
|
|
return Object.entries(loadSessionStoreMock(resolvedStorePath)).map(([sessionKey, entry]) => ({
|
|
sessionKey,
|
|
entry,
|
|
}));
|
|
});
|
|
readSessionUpdatedAtMock.mockReset();
|
|
readSessionUpdatedAtMock.mockReturnValue(undefined);
|
|
recordInboundSessionMock.mockReset();
|
|
recordInboundSessionMock.mockResolvedValue(undefined);
|
|
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({
|
|
applied: true,
|
|
approval: {
|
|
id: "test-approval",
|
|
urlPath: "/approve/test-approval",
|
|
createdAtMs: 1,
|
|
expiresAtMs: 60_000,
|
|
resolvedAtMs: 2,
|
|
reason: "user",
|
|
status: "allowed",
|
|
decision: "allow-once",
|
|
presentation: {
|
|
kind: "exec",
|
|
commandText: "echo test",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
},
|
|
},
|
|
});
|
|
dispatchReplyWithBufferedBlockDispatcher.mockReset();
|
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
|
async (params: DispatchReplyHarnessParams) =>
|
|
await dispatchHarnessReplies(params, async (dispatchParams) => {
|
|
return await replySpy(dispatchParams.ctx, dispatchParams.replyOptions);
|
|
}),
|
|
);
|
|
syncTelegramMenuCommands.mockReset();
|
|
syncTelegramMenuCommands.mockImplementation(async ({ bot, commandsToRegister }) => {
|
|
await bot.api.setMyCommands(commandsToRegister);
|
|
});
|
|
|
|
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);
|
|
grammySpies.getMeSpy.mockReset();
|
|
grammySpies.getMeSpy.mockResolvedValue({
|
|
username: "openclaw_bot",
|
|
has_topics_enabled: true,
|
|
});
|
|
editMessageTextSpy.mockReset();
|
|
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
|
|
editMessageReplyMarkupSpy.mockReset();
|
|
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
|
|
deleteMessageSpy.mockReset();
|
|
deleteMessageSpy.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;
|
|
});
|