mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(telegram): surface inbound media fetch failures (#100051)
This commit is contained in:
parent
94ff3c6b85
commit
77f0f40bb1
8 changed files with 361 additions and 118 deletions
|
|
@ -22,8 +22,8 @@ describe("isDurablyRetryableInboundMediaError", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retries 408 and 5xx HTTP fetch failures", () => {
|
||||
for (const status of [408, 500, 502, 503, 504]) {
|
||||
it("retries 408, 429, and 5xx HTTP fetch failures", () => {
|
||||
for (const status of [408, 429, 500, 502, 503, 504]) {
|
||||
expect(
|
||||
isDurablyRetryableInboundMediaError(new MediaFetchError("http_error", "x", { status })),
|
||||
).toBe(true);
|
||||
|
|
@ -38,7 +38,7 @@ describe("isDurablyRetryableInboundMediaError", () => {
|
|||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
for (const status of [400, 401, 403, 404, 429]) {
|
||||
for (const status of [400, 401, 403, 404]) {
|
||||
expect(
|
||||
isDurablyRetryableInboundMediaError(new MediaFetchError("http_error", "x", { status })),
|
||||
).toBe(false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,25 @@ import type { Message } from "grammy/types";
|
|||
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||
|
||||
const TELEGRAM_BOT_API_FILE_DOWNLOAD_LIMIT_MB = 20;
|
||||
|
||||
export class TelegramBotApiFileTooLargeError extends MediaFetchError {
|
||||
readonly limitMb = TELEGRAM_BOT_API_FILE_DOWNLOAD_LIMIT_MB;
|
||||
|
||||
constructor(cause: unknown) {
|
||||
super(
|
||||
"max_bytes",
|
||||
`Telegram Bot API cannot download files larger than ${TELEGRAM_BOT_API_FILE_DOWNLOAD_LIMIT_MB} MB`,
|
||||
{ cause, status: 400 },
|
||||
);
|
||||
this.name = "TelegramBotApiFileTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isMediaSizeLimitError(err: unknown): boolean {
|
||||
if (err instanceof TelegramBotApiFileTooLargeError) {
|
||||
return true;
|
||||
}
|
||||
const errMsg = String(err);
|
||||
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
|
||||
}
|
||||
|
|
@ -27,7 +45,10 @@ export function isDurablyRetryableInboundMediaError(err: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
if (err.code === "http_error") {
|
||||
return typeof err.status === "number" && (err.status === 408 || err.status >= 500);
|
||||
return (
|
||||
typeof err.status === "number" &&
|
||||
(err.status === 408 || err.status === 429 || err.status >= 500)
|
||||
);
|
||||
}
|
||||
if (err.code !== "fetch_failed") {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import {
|
|||
isMediaSizeLimitError,
|
||||
isRecoverableMediaGroupError,
|
||||
resolveInboundMediaFileId,
|
||||
TelegramBotApiFileTooLargeError,
|
||||
} from "./bot-handlers.media.js";
|
||||
import type { TelegramMediaRef } from "./bot-message-context.js";
|
||||
import type {
|
||||
|
|
@ -220,6 +221,10 @@ export const registerTelegramHandlers = ({
|
|||
token: opts.token,
|
||||
transport: telegramTransport,
|
||||
});
|
||||
const mediaRuntimeWithAbort = {
|
||||
...mediaRuntimeOptions,
|
||||
abortSignal: opts.fetchAbortSignal,
|
||||
};
|
||||
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
||||
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
|
||||
|
|
@ -1038,7 +1043,7 @@ export const registerTelegramHandlers = ({
|
|||
media = await resolveMedia({
|
||||
ctx,
|
||||
maxBytes: mediaMaxBytes,
|
||||
...mediaRuntimeOptions,
|
||||
...mediaRuntimeWithAbort,
|
||||
});
|
||||
} catch (mediaErr) {
|
||||
if (!isRecoverableMediaGroupError(mediaErr)) {
|
||||
|
|
@ -1422,7 +1427,7 @@ export const registerTelegramHandlers = ({
|
|||
getFile: async () => await bot.api.getFile(replyFileId),
|
||||
},
|
||||
maxBytes: mediaMaxBytes,
|
||||
...mediaRuntimeOptions,
|
||||
...mediaRuntimeWithAbort,
|
||||
});
|
||||
mediaRef = media
|
||||
? {
|
||||
|
|
@ -2310,12 +2315,15 @@ export const registerTelegramHandlers = ({
|
|||
media = await resolveMedia({
|
||||
ctx,
|
||||
maxBytes: mediaMaxBytes,
|
||||
...mediaRuntimeOptions,
|
||||
...mediaRuntimeWithAbort,
|
||||
});
|
||||
} catch (mediaErr) {
|
||||
if (isMediaSizeLimitError(mediaErr)) {
|
||||
if (sendOversizeWarning) {
|
||||
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
|
||||
const limitMb =
|
||||
mediaErr instanceof TelegramBotApiFileTooLargeError
|
||||
? Math.min(mediaErr.limitMb, Math.round(mediaMaxBytes / (1024 * 1024)))
|
||||
: Math.round(mediaMaxBytes / (1024 * 1024));
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
|
|
|
|||
|
|
@ -453,6 +453,84 @@ describe("createTelegramBot channel_post media", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("warns instead of dispatching a placeholder when Telegram getFile fails (#100000)", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 100000,
|
||||
date: 1736380800,
|
||||
document: { file_id: "doc-100000", file_name: "report.pdf" },
|
||||
from: { id: 55, is_bot: false, first_name: "u" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => {
|
||||
throw new Error("Network request for 'getFile' failed!");
|
||||
},
|
||||
});
|
||||
|
||||
await waitForMockCalls(sendMessageSpy, 1);
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||
1234,
|
||||
"⚠️ Failed to download media. Please try again.",
|
||||
expect.objectContaining({
|
||||
reply_parameters: expect.objectContaining({ message_id: 100000 }),
|
||||
}),
|
||||
);
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mediaMaxMb: 100, expectedLimitMb: 20 },
|
||||
{ mediaMaxMb: 10, expectedLimitMb: 10 },
|
||||
])(
|
||||
"reports the effective $expectedLimitMb MB limit for Telegram Bot API failures (#100000)",
|
||||
async ({ mediaMaxMb, expectedLimitMb }) => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"], mediaMaxMb } },
|
||||
});
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 100001,
|
||||
date: 1736380800,
|
||||
document: { file_id: "doc-100001", file_name: "large.bin" },
|
||||
from: { id: 55, is_bot: false, first_name: "u" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => {
|
||||
throw new Error("Bad Request: file is too big");
|
||||
},
|
||||
});
|
||||
|
||||
await waitForMockCalls(sendMessageSpy, 1);
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||
1234,
|
||||
`⚠️ File too large. Maximum size is ${expectedLimitMb}MB.`,
|
||||
expect.objectContaining({
|
||||
reply_parameters: expect.objectContaining({ message_id: 100001 }),
|
||||
}),
|
||||
);
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(saveRemoteMedia).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("durably retries a spooled-replay shutdown-abort document fetch without warning (#98076)", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Telegram tests cover delivery.resolve media retry plugin behavior.
|
||||
import { GrammyError } from "grammy";
|
||||
import type { Message } from "grammy/types";
|
||||
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMedia } from "./delivery.resolve-media.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
|
|
@ -43,11 +44,13 @@ vi.mock("openclaw/plugin-sdk/file-access-runtime", () => ({
|
|||
vi.mock("./delivery.resolve-media.runtime.js", () => {
|
||||
class MediaFetchError extends Error {
|
||||
code: string;
|
||||
status?: number;
|
||||
|
||||
constructor(code: string, message: string, options?: { cause?: unknown }) {
|
||||
constructor(code: string, message: string, options?: { cause?: unknown; status?: number }) {
|
||||
super(message, options);
|
||||
this.name = "MediaFetchError";
|
||||
this.code = code;
|
||||
this.status = options?.status;
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -57,11 +60,10 @@ vi.mock("./delivery.resolve-media.runtime.js", () => {
|
|||
MediaFetchError,
|
||||
resolveTelegramApiBase: (apiRoot?: string) =>
|
||||
apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org",
|
||||
retryAsync,
|
||||
sleepWithAbort,
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args),
|
||||
shouldRetryTelegramTransportFallback: vi.fn(() => false),
|
||||
warn: (s: string) => s,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -191,6 +193,34 @@ function createFileTooBigError(): Error {
|
|||
return new Error("GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)");
|
||||
}
|
||||
|
||||
function createFileTooBigGrammyError(): GrammyError {
|
||||
return new GrammyError(
|
||||
"Call to 'getFile' failed!",
|
||||
{
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: "Bad Request: file is too big",
|
||||
parameters: {},
|
||||
},
|
||||
"getFile",
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function createRateLimitGrammyError(retryAfterSeconds = 3): GrammyError {
|
||||
return new GrammyError(
|
||||
"Call to 'getFile' failed!",
|
||||
{
|
||||
ok: false,
|
||||
error_code: 429,
|
||||
description: "Too Many Requests: retry later",
|
||||
parameters: { retry_after: retryAfterSeconds },
|
||||
},
|
||||
"getFile",
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMediaWithDefaults(
|
||||
ctx: TelegramContext,
|
||||
overrides: Partial<Parameters<typeof resolveMedia>[0]> = {},
|
||||
|
|
@ -253,15 +283,18 @@ function expectResolvedMediaFields(
|
|||
|
||||
async function expectMediaFetchError(
|
||||
promise: Promise<unknown>,
|
||||
fields: { code: string; messageIncludes: string },
|
||||
fields: { code: string; messageIncludes: string; name?: string; status?: number },
|
||||
) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
const record = requireRecord(error, "MediaFetchError");
|
||||
expect(record.name).toBe("MediaFetchError");
|
||||
expect(record.name).toBe(fields.name ?? "MediaFetchError");
|
||||
expect(record.code).toBe(fields.code);
|
||||
expect(String(record.message)).toContain(fields.messageIncludes);
|
||||
if (fields.status !== undefined) {
|
||||
expect(record.status).toBe(fields.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error("expected MediaFetchError rejection");
|
||||
|
|
@ -321,16 +354,19 @@ describe("resolveMedia getFile retry", () => {
|
|||
});
|
||||
|
||||
it.each(["voice", "photo", "video"] as const)(
|
||||
"returns null for %s when getFile exhausts retries so message is not dropped",
|
||||
"throws a typed failure for %s when getFile exhausts retries",
|
||||
async (mediaField) => {
|
||||
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
|
||||
|
||||
const promise = resolveMediaWithDefaults(makeCtx(mediaField, getFile));
|
||||
const failure = expectMediaFetchError(promise, {
|
||||
code: "fetch_failed",
|
||||
messageIncludes: "Telegram getFile failed after retries",
|
||||
});
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
await failure;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(3);
|
||||
expect(result).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -345,39 +381,117 @@ describe("resolveMedia getFile retry", () => {
|
|||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {
|
||||
it("does not retry string-shaped 'file is too big' errors", async () => {
|
||||
// Simulate Telegram Bot API error when file exceeds 20MB limit.
|
||||
const fileTooBigError = createFileTooBigError();
|
||||
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
|
||||
await expectMediaFetchError(resolveMediaWithDefaults(makeCtx("video", getFile)), {
|
||||
code: "max_bytes",
|
||||
messageIncludes: "larger than 20 MB",
|
||||
name: "TelegramBotApiFileTooLargeError",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
// Should NOT retry - "file is too big" is a permanent error, not transient.
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry 'file is too big' GrammyError instances and returns null", async () => {
|
||||
const fileTooBigError = new Error(
|
||||
"GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)",
|
||||
);
|
||||
it("preserves Telegram status for 'file is too big' GrammyError instances", async () => {
|
||||
const fileTooBigError = createFileTooBigGrammyError();
|
||||
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx("video", getFile));
|
||||
await expectMediaFetchError(resolveMediaWithDefaults(makeCtx("video", getFile)), {
|
||||
code: "max_bytes",
|
||||
messageIncludes: "larger than 20 MB",
|
||||
name: "TelegramBotApiFileTooLargeError",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors Telegram retry_after before retrying getFile", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(createRateLimitGrammyError())
|
||||
.mockResolvedValueOnce({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const promise = resolveMediaWithDefaults(makeCtx("document", getFile));
|
||||
await vi.advanceTimersByTimeAsync(2_999);
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cap Telegram retry_after at 30 seconds", async () => {
|
||||
const getFile = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(createRateLimitGrammyError(60))
|
||||
.mockResolvedValueOnce({ file_path: "documents/file_42.pdf" });
|
||||
mockPdfFetchAndSave("file_42.pdf");
|
||||
|
||||
const promise = resolveMediaWithDefaults(makeCtx("document", getFile));
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("aborts long retry_after waits at the overall handler deadline", async () => {
|
||||
const getFile = vi.fn().mockRejectedValue(createRateLimitGrammyError(1_200));
|
||||
const startedAt = Date.now();
|
||||
|
||||
const promise = resolveMediaWithDefaults(makeCtx("document", getFile));
|
||||
const failure = expectMediaFetchError(promise, {
|
||||
code: "http_error",
|
||||
messageIncludes: "Telegram getFile failed after retries",
|
||||
status: 429,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await failure;
|
||||
|
||||
expect(getFile.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
expect(Date.now() - startedAt).toBeLessThan(25 * 60_000);
|
||||
});
|
||||
|
||||
it("aborts retry_after waits when the Telegram session shuts down", async () => {
|
||||
const shutdown = new AbortController();
|
||||
const getFile = vi.fn().mockRejectedValue(createRateLimitGrammyError(60));
|
||||
const promise = resolveMediaWithDefaults(makeCtx("document", getFile), {
|
||||
abortSignal: shutdown.signal,
|
||||
});
|
||||
const failure = expectMediaFetchError(promise, {
|
||||
code: "http_error",
|
||||
messageIncludes: "Telegram getFile failed after retries",
|
||||
status: 429,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
shutdown.abort();
|
||||
await failure;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["audio", "voice"] as const)(
|
||||
"returns null for %s when file is too big",
|
||||
"throws a typed failure for %s when file is too big",
|
||||
async (mediaField) => {
|
||||
const getFile = vi.fn().mockRejectedValue(createFileTooBigError());
|
||||
|
||||
const result = await resolveMediaWithDefaults(makeCtx(mediaField, getFile));
|
||||
await expectMediaFetchError(resolveMediaWithDefaults(makeCtx(mediaField, getFile)), {
|
||||
code: "max_bytes",
|
||||
messageIncludes: "larger than 20 MB",
|
||||
name: "TelegramBotApiFileTooLargeError",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -423,16 +537,19 @@ describe("resolveMedia getFile retry", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("returns null for sticker when getFile exhausts retries", async () => {
|
||||
it("throws a typed failure for stickers when getFile exhausts retries", async () => {
|
||||
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
|
||||
|
||||
const ctx = makeCtx("sticker", getFile);
|
||||
const promise = resolveMediaWithDefaults(ctx);
|
||||
const failure = expectMediaFetchError(promise, {
|
||||
code: "fetch_failed",
|
||||
messageIncludes: "Telegram getFile failed after retries",
|
||||
});
|
||||
await flushRetryTimers();
|
||||
const result = await promise;
|
||||
await failure;
|
||||
|
||||
expect(getFile).toHaveBeenCalledTimes(3);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses caller-provided fetch impl for file downloads", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Telegram plugin module implements delivery.resolve media behavior.
|
||||
import { logVerbose, retryAsync, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveTelegramApiBase, shouldRetryTelegramTransportFallback } from "../fetch.js";
|
||||
import {
|
||||
|
|
@ -15,9 +15,8 @@ export {
|
|||
logVerbose,
|
||||
MediaFetchError,
|
||||
resolveTelegramApiBase,
|
||||
retryAsync,
|
||||
sleepWithAbort,
|
||||
saveMediaBuffer,
|
||||
saveRemoteMedia,
|
||||
shouldRetryTelegramTransportFallback,
|
||||
warn,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,23 +2,26 @@
|
|||
import path from "node:path";
|
||||
import { GrammyError } from "grammy";
|
||||
import { root as fsRoot } from "openclaw/plugin-sdk/file-access-runtime";
|
||||
import { TelegramBotApiFileTooLargeError } from "../bot-handlers.media.js";
|
||||
import type { TelegramTransport } from "../fetch.js";
|
||||
import { readTelegramRetryAfterMs } from "../network-errors.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
import {
|
||||
formatErrorMessage,
|
||||
logVerbose,
|
||||
MediaFetchError,
|
||||
resolveTelegramApiBase,
|
||||
retryAsync,
|
||||
saveMediaBuffer,
|
||||
saveRemoteMedia,
|
||||
shouldRetryTelegramTransportFallback,
|
||||
warn,
|
||||
sleepWithAbort,
|
||||
} from "./delivery.resolve-media.runtime.js";
|
||||
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||
const TELEGRAM_GET_FILE_RETRY_DEADLINE_MS = 20 * 60_000;
|
||||
const TELEGRAM_GET_FILE_RETRY_ATTEMPTS = 3;
|
||||
const GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
|
|
@ -112,32 +115,53 @@ function resolveMediaMetadata(msg: TelegramContext["message"]): MediaMetadata {
|
|||
|
||||
async function resolveTelegramFileWithRetry(
|
||||
ctx: TelegramContext,
|
||||
): Promise<{ file_path?: string } | null> {
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ file_path?: string }> {
|
||||
const deadline = new AbortController();
|
||||
const deadlineTimer = setTimeout(
|
||||
() => deadline.abort(new Error("Telegram getFile retry deadline exceeded")),
|
||||
TELEGRAM_GET_FILE_RETRY_DEADLINE_MS,
|
||||
);
|
||||
deadlineTimer.unref?.();
|
||||
const signal = abortSignal ? AbortSignal.any([abortSignal, deadline.signal]) : deadline.signal;
|
||||
// grammY ships a compatible AbortSignal runtime with a structurally distinct
|
||||
// declaration, so keep the cast at this dependency boundary.
|
||||
const getFileSignal = signal as Parameters<TelegramContext["getFile"]>[0];
|
||||
try {
|
||||
return await retryAsync(() => ctx.getFile(), {
|
||||
attempts: 3,
|
||||
minDelayMs: 1000,
|
||||
maxDelayMs: 4000,
|
||||
jitter: 0.2,
|
||||
label: "telegram:getFile",
|
||||
shouldRetry: isRetryableGetFileError,
|
||||
onRetry: ({ attempt, maxAttempts }) =>
|
||||
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
|
||||
if (isFileTooBigError(err)) {
|
||||
logVerbose(
|
||||
warn(
|
||||
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
|
||||
),
|
||||
);
|
||||
return null;
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await ctx.getFile(getFileSignal);
|
||||
} catch (err) {
|
||||
if (attempt >= TELEGRAM_GET_FILE_RETRY_ATTEMPTS || !isRetryableGetFileError(err)) {
|
||||
throw err;
|
||||
}
|
||||
logVerbose(`telegram: getFile retry ${attempt}/${TELEGRAM_GET_FILE_RETRY_ATTEMPTS}`);
|
||||
try {
|
||||
await sleepWithAbort(readTelegramRetryAfterMs(err) ?? 1000 * 2 ** (attempt - 1), signal);
|
||||
} catch {
|
||||
// Cancellation must not erase the retryable Telegram/network error
|
||||
// that caused this wait; the spool classifier needs its status/cause.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
// All retries exhausted — return null so the message still reaches the agent
|
||||
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
|
||||
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isFileTooBigError(err)) {
|
||||
throw new TelegramBotApiFileTooLargeError(err);
|
||||
}
|
||||
const status = GrammyErrorCtor && err instanceof GrammyErrorCtor ? err.error_code : undefined;
|
||||
// Keep getFile failures on the same typed path as download failures so the
|
||||
// handler can warn the user and durably retry transient spooled updates.
|
||||
throw new MediaFetchError(
|
||||
status ? "http_error" : "fetch_failed",
|
||||
`Telegram getFile failed after retries: ${formatErrorMessage(err)}`,
|
||||
{
|
||||
cause: err,
|
||||
status,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(deadlineTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +294,7 @@ async function resolveStickerMedia(params: {
|
|||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<
|
||||
| {
|
||||
path: string;
|
||||
|
|
@ -280,7 +305,7 @@ async function resolveStickerMedia(params: {
|
|||
| null
|
||||
| undefined
|
||||
> {
|
||||
const { msg, ctx, maxBytes, token, transport } = params;
|
||||
const { msg, ctx, maxBytes, token, transport, abortSignal } = params;
|
||||
if (!msg.sticker) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -294,68 +319,62 @@ async function resolveStickerMedia(params: {
|
|||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await resolveTelegramFileWithRetry(ctx);
|
||||
if (!file?.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile({
|
||||
filePath: file.file_path,
|
||||
token,
|
||||
transport,
|
||||
maxBytes,
|
||||
apiRoot: params.apiRoot,
|
||||
trustedLocalFileRoots: params.trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork,
|
||||
});
|
||||
const file = await resolveTelegramFileWithRetry(ctx, abortSignal);
|
||||
if (!file.file_path) {
|
||||
throw new Error("Telegram getFile returned no file_path for sticker");
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile({
|
||||
filePath: file.file_path,
|
||||
token,
|
||||
transport,
|
||||
maxBytes,
|
||||
apiRoot: params.apiRoot,
|
||||
trustedLocalFileRoots: params.trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork: params.dangerouslyAllowPrivateNetwork,
|
||||
});
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMedia(params: {
|
||||
|
|
@ -366,6 +385,7 @@ export async function resolveMedia(params: {
|
|||
apiRoot?: string;
|
||||
trustedLocalFileRoots?: readonly string[];
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
|
|
@ -380,6 +400,7 @@ export async function resolveMedia(params: {
|
|||
apiRoot,
|
||||
trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork,
|
||||
abortSignal,
|
||||
} = params;
|
||||
const msg = ctx.message;
|
||||
const stickerResolved = await resolveStickerMedia({
|
||||
|
|
@ -391,6 +412,7 @@ export async function resolveMedia(params: {
|
|||
apiRoot,
|
||||
trustedLocalFileRoots,
|
||||
dangerouslyAllowPrivateNetwork,
|
||||
abortSignal,
|
||||
});
|
||||
if (stickerResolved !== undefined) {
|
||||
return stickerResolved;
|
||||
|
|
@ -402,10 +424,7 @@ export async function resolveMedia(params: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const file = await resolveTelegramFileWithRetry(ctx);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
const file = await resolveTelegramFileWithRetry(ctx, abortSignal);
|
||||
if (!file.file_path) {
|
||||
throw new Error("Telegram getFile returned no file_path");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Telegram type declarations define plugin contracts.
|
||||
import type { Context } from "grammy";
|
||||
import type { ChatFullInfo, Message, UserFromGetMe } from "grammy/types";
|
||||
|
||||
/** App-specific stream mode for Telegram stream previews. */
|
||||
export type TelegramStreamMode = "off" | "partial" | "block" | "progress";
|
||||
|
||||
type TelegramGetFile = () => Promise<{ file_path?: string }>;
|
||||
type TelegramGetFile = Context["getFile"];
|
||||
export type TelegramChatDetails = {
|
||||
id?: number | string;
|
||||
available_reactions?: ChatFullInfo["available_reactions"] | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue