fix(telegram): surface inbound media fetch failures (#100051)

This commit is contained in:
Peter Steinberger 2026-07-04 14:54:43 -04:00 committed by GitHub
parent 94ff3c6b85
commit 77f0f40bb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 361 additions and 118 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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: ["*"] } },

View file

@ -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 () => {

View file

@ -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,
};

View file

@ -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");
}

View file

@ -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;