mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
b080dd1e76
* refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
// Telegram tests cover bot.create telegram bot.media group skip warning plugin behavior.
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js";
|
|
|
|
const saveRemoteMedia = vi.fn();
|
|
const saveMediaBuffer = vi.fn();
|
|
const readRemoteMediaBuffer = vi.fn();
|
|
const rootRead = vi.fn();
|
|
|
|
vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
|
|
root: async (rootDir: string) => ({
|
|
read: async (relativePath: string, options?: { maxBytes?: number }) =>
|
|
await rootRead({ rootDir, relativePath, maxBytes: options?.maxBytes }),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./bot/delivery.resolve-media.runtime.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/telegram-media.runtime.js")>(
|
|
"./telegram-media.runtime.js",
|
|
);
|
|
return {
|
|
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args),
|
|
formatErrorMessage: coerceErrorMessage,
|
|
logVerbose: () => {},
|
|
MediaFetchError: actual.MediaFetchError,
|
|
resolveTelegramApiBase: (apiRoot?: string) =>
|
|
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
|
|
retryAsync: async (fn: () => unknown) => await fn(),
|
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
|
saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args),
|
|
shouldRetryTelegramTransportFallback: vi.fn(() => false),
|
|
warn: (s: string) => s,
|
|
};
|
|
});
|
|
|
|
vi.mock("./sticker-cache.js", () => ({
|
|
cacheSticker: () => {},
|
|
getCachedSticker: () => null,
|
|
getCacheStats: () => ({ count: 0 }),
|
|
searchStickers: () => [],
|
|
getAllCachedStickers: () => [],
|
|
describeStickerImage: async () => null,
|
|
}));
|
|
|
|
const harness = await import("./bot.create-telegram-bot.test-harness.js");
|
|
const { getLoadConfigMock, getOnHandler, replySpy, sendMessageSpy, telegramBotDepsForTest } =
|
|
harness;
|
|
const { createTelegramBotCore: createTelegramBotBase } = await import("./bot-core.js");
|
|
const { MediaFetchError } = await import("./telegram-media.runtime.js");
|
|
|
|
let createTelegramBot: (
|
|
opts: import("./bot.types.js").TelegramBotOptions,
|
|
) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
|
|
|
|
const loadConfig = getLoadConfigMock();
|
|
|
|
const TELEGRAM_TEST_TIMINGS = {
|
|
mediaGroupFlushMs: 20,
|
|
textFragmentGapMs: 30,
|
|
} as const;
|
|
|
|
const CHANNEL_ID = -100777111222;
|
|
|
|
function setOpenChannelPostConfig() {
|
|
loadConfig.mockReturnValue({
|
|
channels: {
|
|
telegram: {
|
|
groupPolicy: "open",
|
|
groups: {
|
|
"-100777111222": {
|
|
enabled: true,
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function getChannelPostHandler() {
|
|
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
|
|
return getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
|
|
}
|
|
|
|
function resolveFlushTimer(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
|
|
const delayMs = TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs;
|
|
const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
|
(call: Parameters<typeof setTimeout>) => call[1] === delayMs,
|
|
);
|
|
const flushTimer =
|
|
flushTimerCallIndex >= 0
|
|
? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined)
|
|
: undefined;
|
|
if (flushTimerCallIndex >= 0) {
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
}
|
|
return flushTimer;
|
|
}
|
|
|
|
async function waitForBufferedProcessing() {
|
|
await delay(75);
|
|
}
|
|
|
|
async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
|
|
const flushTimer = resolveFlushTimer(setTimeoutSpy);
|
|
expect(flushTimer).toBeTypeOf("function");
|
|
await flushTimer?.();
|
|
await waitForBufferedProcessing();
|
|
}
|
|
|
|
function createChannelPostContext(params: {
|
|
messageId: number;
|
|
date: number;
|
|
caption?: string;
|
|
mediaGroupId: string;
|
|
photoFileId: string;
|
|
}) {
|
|
return {
|
|
channelPost: {
|
|
chat: { id: CHANNEL_ID, type: "channel", title: "Wake Channel" },
|
|
message_id: params.messageId,
|
|
date: params.date,
|
|
...(params.caption ? { caption: params.caption } : {}),
|
|
media_group_id: params.mediaGroupId,
|
|
photo: [{ file_id: params.photoFileId }],
|
|
},
|
|
me: { username: "openclaw_bot" },
|
|
getFile: async () => ({ file_path: `photos/${params.photoFileId}.jpg` }),
|
|
};
|
|
}
|
|
|
|
async function queueChannelPostAlbum(
|
|
handler: (ctx: Record<string, unknown>) => Promise<void>,
|
|
params: {
|
|
baseMessageId: number;
|
|
caption: string;
|
|
mediaGroupId: string;
|
|
photoFileIds: string[];
|
|
},
|
|
) {
|
|
const calls = params.photoFileIds.map((fileId, index) =>
|
|
handler(
|
|
createChannelPostContext({
|
|
messageId: params.baseMessageId + index,
|
|
date: 1736380800 + index,
|
|
...(index === 0 ? { caption: params.caption } : {}),
|
|
mediaGroupId: params.mediaGroupId,
|
|
photoFileId: fileId,
|
|
}),
|
|
),
|
|
);
|
|
await Promise.all(calls);
|
|
return params.baseMessageId;
|
|
}
|
|
|
|
function urlOf(args: unknown[]): string {
|
|
const opts = args[0];
|
|
if (opts && typeof opts === "object" && "url" in opts) {
|
|
return String((opts as { url: unknown }).url);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
describe("createTelegramBot media-group skip warning (#55216)", () => {
|
|
beforeAll(() => {
|
|
createTelegramBot = (opts) =>
|
|
createTelegramBotBase({
|
|
botInfo: telegramBotInfoForTest,
|
|
...opts,
|
|
telegramDeps: telegramBotDepsForTest,
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
saveRemoteMedia.mockReset();
|
|
saveMediaBuffer.mockReset();
|
|
readRemoteMediaBuffer.mockReset();
|
|
rootRead.mockReset();
|
|
sendMessageSpy.mockClear();
|
|
replySpy.mockClear();
|
|
});
|
|
|
|
it("warns the user once when an album drops some images", async () => {
|
|
setOpenChannelPostConfig();
|
|
saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
|
|
const url = urlOf(args);
|
|
if (url.includes("photos/p1.jpg")) {
|
|
return { path: "/tmp/p1.jpg", contentType: "image/png" };
|
|
}
|
|
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}`);
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
try {
|
|
const handler = getChannelPostHandler();
|
|
const baseMessageId = await queueChannelPostAlbum(handler, {
|
|
baseMessageId: 600,
|
|
caption: "album caption",
|
|
mediaGroupId: "skip-warn-album-1",
|
|
photoFileIds: ["p1", "p2"],
|
|
});
|
|
expect(sendMessageSpy).not.toHaveBeenCalled();
|
|
await flushChannelPostMediaGroup(setTimeoutSpy);
|
|
|
|
await vi.waitFor(() => expect(sendMessageSpy).toHaveBeenCalledTimes(1));
|
|
expect(sendMessageSpy).toHaveBeenCalledWith(
|
|
CHANNEL_ID,
|
|
expect.stringContaining("1 of 2 images"),
|
|
expect.objectContaining({
|
|
reply_parameters: expect.objectContaining({
|
|
message_id: baseMessageId,
|
|
allow_sending_without_reply: true,
|
|
}),
|
|
}),
|
|
);
|
|
const warningText = String(sendMessageSpy.mock.calls[0]?.[1]);
|
|
expect(warningText).toContain("1 could not be fetched and was skipped");
|
|
await vi.waitFor(() => expect(replySpy).toHaveBeenCalled());
|
|
expect(replySpy.mock.calls[0]?.[0]?.media).toEqual([
|
|
expect.objectContaining({ path: "/tmp/p1.jpg", contentType: "image/png" }),
|
|
expect.objectContaining({ kind: "image" }),
|
|
]);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("warns the user when every album image fails", async () => {
|
|
setOpenChannelPostConfig();
|
|
saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
|
|
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${urlOf(args)}`);
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
try {
|
|
const handler = getChannelPostHandler();
|
|
await queueChannelPostAlbum(handler, {
|
|
baseMessageId: 700,
|
|
caption: "all-fail album",
|
|
mediaGroupId: "skip-warn-album-2",
|
|
photoFileIds: ["p1", "p2"],
|
|
});
|
|
await flushChannelPostMediaGroup(setTimeoutSpy);
|
|
|
|
await vi.waitFor(() => expect(sendMessageSpy).toHaveBeenCalledTimes(1));
|
|
const warningText = String(sendMessageSpy.mock.calls[0]?.[1]);
|
|
expect(warningText).toContain("0 of 2 images");
|
|
expect(warningText).toContain("2 could not be fetched and were skipped");
|
|
expect(replySpy.mock.calls[0]?.[0]?.media).toEqual([
|
|
expect.objectContaining({ kind: "image" }),
|
|
expect.objectContaining({ kind: "image" }),
|
|
]);
|
|
expect(replySpy.mock.calls[0]?.[0]?.media?.every((fact) => !fact.path)).toBe(true);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("pluralizes correctly for 2+ skipped", async () => {
|
|
setOpenChannelPostConfig();
|
|
saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
|
|
const url = urlOf(args);
|
|
if (url.includes("photos/p1.jpg")) {
|
|
return { path: "/tmp/p1.jpg", contentType: "image/png" };
|
|
}
|
|
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}`);
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
try {
|
|
const handler = getChannelPostHandler();
|
|
await queueChannelPostAlbum(handler, {
|
|
baseMessageId: 800,
|
|
caption: "plural album",
|
|
mediaGroupId: "skip-warn-album-3",
|
|
photoFileIds: ["p1", "p2", "p3"],
|
|
});
|
|
await flushChannelPostMediaGroup(setTimeoutSpy);
|
|
|
|
await vi.waitFor(() => expect(sendMessageSpy).toHaveBeenCalledTimes(1));
|
|
const warningText = String(sendMessageSpy.mock.calls[0]?.[1]);
|
|
expect(warningText).toContain("1 of 3 images");
|
|
expect(warningText).toContain("2 could not be fetched and were skipped");
|
|
expect(replySpy.mock.calls[0]?.[0]?.media).toEqual([
|
|
expect.objectContaining({ path: "/tmp/p1.jpg", contentType: "image/png" }),
|
|
expect.objectContaining({ kind: "image" }),
|
|
expect.objectContaining({ kind: "image" }),
|
|
]);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|