

















@@ -0,0 +1,280 @@
1+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+3+// Resolve a real photo to a stored path for "p1.jpg" while making "p2.jpg"
4+// (and "p3.jpg") fail with a *real* MediaFetchError so that
5+// isRecoverableMediaGroupError() (which checks `instanceof MediaFetchError`
6+// against openclaw/plugin-sdk/media-runtime) treats the failure as a
7+// recoverable per-photo skip rather than dropping the whole album.
8+const saveRemoteMedia = vi.fn();
9+const saveMediaBuffer = vi.fn();
10+const readRemoteMediaBuffer = vi.fn();
11+const rootRead = vi.fn();
12+13+vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
14+root: async (rootDir: string) => ({
15+read: async (relativePath: string, options?: { maxBytes?: number }) =>
16+await rootRead({ rootDir, relativePath, maxBytes: options?.maxBytes }),
17+}),
18+}));
19+20+vi.mock("./bot/delivery.resolve-media.runtime.js", async () => {
21+const actual = await vi.importActual<typeof import("../src/telegram-media.runtime.js")>(
22+"./telegram-media.runtime.js",
23+);
24+return {
25+readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args),
26+formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
27+logVerbose: () => {},
28+MediaFetchError: actual.MediaFetchError,
29+resolveTelegramApiBase: (apiRoot?: string) =>
30+apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
31+retryAsync: async (fn: () => unknown) => await fn(),
32+saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
33+saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args),
34+shouldRetryTelegramTransportFallback: vi.fn(() => false),
35+warn: (s: string) => s,
36+};
37+});
38+39+vi.mock("./sticker-cache.js", () => ({
40+cacheSticker: () => {},
41+getCachedSticker: () => null,
42+getCacheStats: () => ({ count: 0 }),
43+searchStickers: () => [],
44+getAllCachedStickers: () => [],
45+describeStickerImage: async () => null,
46+}));
47+48+const harness = await import("./bot.create-telegram-bot.test-harness.js");
49+const {
50+ getLoadConfigMock,
51+ getOnHandler,
52+ replySpy,
53+ sendMessageSpy,
54+ telegramBotDepsForTest,
55+ telegramBotRuntimeForTest,
56+} = harness;
57+const { createTelegramBotCore: createTelegramBotBase, setTelegramBotRuntimeForTest } =
58+await import("./bot-core.js");
59+const { MediaFetchError } = await import("./telegram-media.runtime.js");
60+61+let createTelegramBot: (
62+opts: import("./bot.types.js").TelegramBotOptions,
63+) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
64+65+const loadConfig = getLoadConfigMock();
66+67+const TELEGRAM_TEST_TIMINGS = {
68+mediaGroupFlushMs: 20,
69+textFragmentGapMs: 30,
70+} as const;
71+72+const CHANNEL_ID = -100777111222;
73+74+function setOpenChannelPostConfig() {
75+loadConfig.mockReturnValue({
76+channels: {
77+telegram: {
78+groupPolicy: "open",
79+groups: {
80+"-100777111222": {
81+enabled: true,
82+requireMention: false,
83+},
84+},
85+},
86+},
87+});
88+}
89+90+function getChannelPostHandler() {
91+createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
92+return getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
93+}
94+95+function resolveFlushTimer(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
96+const delayMs = TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs;
97+const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
98+(call: Parameters<typeof setTimeout>) => call[1] === delayMs,
99+);
100+const flushTimer =
101+flushTimerCallIndex >= 0
102+ ? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined)
103+ : undefined;
104+if (flushTimerCallIndex >= 0) {
105+clearTimeout(
106+setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType<typeof setTimeout>,
107+);
108+}
109+return flushTimer;
110+}
111+112+async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
113+const flushTimer = resolveFlushTimer(setTimeoutSpy);
114+expect(flushTimer).toBeTypeOf("function");
115+await flushTimer?.();
116+}
117+118+function createChannelPostContext(params: {
119+messageId: number;
120+date: number;
121+caption?: string;
122+mediaGroupId: string;
123+photoFileId: string;
124+}) {
125+return {
126+channelPost: {
127+chat: { id: CHANNEL_ID, type: "channel", title: "Wake Channel" },
128+message_id: params.messageId,
129+date: params.date,
130+ ...(params.caption ? { caption: params.caption } : {}),
131+media_group_id: params.mediaGroupId,
132+photo: [{ file_id: params.photoFileId }],
133+},
134+me: { username: "openclaw_bot" },
135+getFile: async () => ({ file_path: `photos/${params.photoFileId}.jpg` }),
136+};
137+}
138+139+async function queueChannelPostAlbum(
140+handler: (ctx: Record<string, unknown>) => Promise<void>,
141+params: { caption: string; mediaGroupId: string; photoFileIds: string[] },
142+) {
143+const baseMessageId = 600;
144+const calls = params.photoFileIds.map((fileId, index) =>
145+handler(
146+createChannelPostContext({
147+messageId: baseMessageId + index,
148+date: 1736380800 + index,
149+ ...(index === 0 ? { caption: params.caption } : {}),
150+mediaGroupId: params.mediaGroupId,
151+photoFileId: fileId,
152+}),
153+),
154+);
155+await Promise.all(calls);
156+return baseMessageId;
157+}
158+159+function urlOf(args: unknown[]): string {
160+const opts = args[0];
161+if (opts && typeof opts === "object" && "url" in opts) {
162+return String((opts as { url: unknown }).url);
163+}
164+return "";
165+}
166+167+describe("createTelegramBot media-group skip warning (#55216)", () => {
168+beforeAll(() => {
169+createTelegramBot = (opts) =>
170+createTelegramBotBase({
171+ ...opts,
172+telegramDeps: telegramBotDepsForTest,
173+});
174+setTelegramBotRuntimeForTest(
175+telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
176+);
177+});
178+179+beforeEach(() => {
180+setTelegramBotRuntimeForTest(
181+telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
182+);
183+saveRemoteMedia.mockReset();
184+saveMediaBuffer.mockReset();
185+readRemoteMediaBuffer.mockReset();
186+rootRead.mockReset();
187+sendMessageSpy.mockClear();
188+replySpy.mockClear();
189+});
190+191+it("warns the user once when an album drops some images", async () => {
192+setOpenChannelPostConfig();
193+saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
194+const url = urlOf(args);
195+if (url.includes("photos/p1.jpg")) {
196+return { path: "/tmp/p1.jpg", contentType: "image/png" };
197+}
198+throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}`);
199+});
200+201+const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
202+try {
203+const handler = getChannelPostHandler();
204+const baseMessageId = await queueChannelPostAlbum(handler, {
205+caption: "album caption",
206+mediaGroupId: "skip-warn-album-1",
207+photoFileIds: ["p1", "p2"],
208+});
209+expect(sendMessageSpy).not.toHaveBeenCalled();
210+await flushChannelPostMediaGroup(setTimeoutSpy);
211+212+expect(sendMessageSpy).toHaveBeenCalledTimes(1);
213+expect(sendMessageSpy).toHaveBeenCalledWith(
214+CHANNEL_ID,
215+expect.stringContaining("1 of 2 images"),
216+expect.objectContaining({
217+reply_parameters: expect.objectContaining({
218+message_id: baseMessageId,
219+allow_sending_without_reply: true,
220+}),
221+}),
222+);
223+const warningText = String(sendMessageSpy.mock.calls[0]?.[1]);
224+expect(warningText).toContain("1 could not be fetched and was skipped");
225+} finally {
226+setTimeoutSpy.mockRestore();
227+}
228+});
229+230+it("does not send a media-drop warning when every photo fails", async () => {
231+setOpenChannelPostConfig();
232+saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
233+throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${urlOf(args)}`);
234+});
235+236+const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
237+try {
238+const handler = getChannelPostHandler();
239+await queueChannelPostAlbum(handler, {
240+caption: "all-fail album",
241+mediaGroupId: "skip-warn-album-2",
242+photoFileIds: ["p1", "p2"],
243+});
244+await flushChannelPostMediaGroup(setTimeoutSpy);
245+246+expect(sendMessageSpy).not.toHaveBeenCalled();
247+} finally {
248+setTimeoutSpy.mockRestore();
249+}
250+});
251+252+it("pluralizes correctly for 2+ skipped", async () => {
253+setOpenChannelPostConfig();
254+saveRemoteMedia.mockImplementation(async (...args: unknown[]) => {
255+const url = urlOf(args);
256+if (url.includes("photos/p1.jpg")) {
257+return { path: "/tmp/p1.jpg", contentType: "image/png" };
258+}
259+throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}`);
260+});
261+262+const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
263+try {
264+const handler = getChannelPostHandler();
265+await queueChannelPostAlbum(handler, {
266+caption: "plural album",
267+mediaGroupId: "skip-warn-album-3",
268+photoFileIds: ["p1", "p2", "p3"],
269+});
270+await flushChannelPostMediaGroup(setTimeoutSpy);
271+272+expect(sendMessageSpy).toHaveBeenCalledTimes(1);
273+const warningText = String(sendMessageSpy.mock.calls[0]?.[1]);
274+expect(warningText).toContain("1 of 3 images");
275+expect(warningText).toContain("2 could not be fetched and were skipped");
276+} finally {
277+setTimeoutSpy.mockRestore();
278+}
279+});
280+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。