diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 1b867efdba1..26a9cb88c34 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -295,7 +295,6 @@ extensions/telegram/src/bot-native-commands.ts extensions/telegram/src/bot.create-telegram-bot.test.ts extensions/telegram/src/bot.test.ts extensions/telegram/src/bot/delivery.replies.ts -extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts extensions/telegram/src/bot/delivery.test.ts extensions/telegram/src/channel.ts extensions/telegram/src/draft-stream.test.ts @@ -304,7 +303,6 @@ extensions/telegram/src/fetch.test.ts extensions/telegram/src/fetch.ts extensions/telegram/src/format.ts extensions/telegram/src/lane-delivery.test.ts -extensions/telegram/src/message-cache.test.ts extensions/telegram/src/message-cache.ts extensions/telegram/src/polling-session.test.ts extensions/telegram/src/polling-session.ts diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index f12756f9525..909ad990158 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -55,7 +55,6 @@ export { resolveTelegramForumFlag, resolveTelegramForumThreadId, resolveTelegramGroupAllowFromContext, - resolveTelegramMediaPlaceholder, resolveTelegramReplyId, resolveTelegramStreamMode, resolveTelegramThreadSpec, diff --git a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts index 81769918a0c..bb042ef04f2 100644 --- a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts @@ -24,7 +24,7 @@ import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; -import { getTelegramTextParts, hasBotMention } from "./bot/helpers.js"; +import { getTelegramTextParts, hasBotMention, resolveTelegramPrimaryMedia } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; @@ -224,9 +224,11 @@ export function createTelegramInboundMediaGroupRuntime( } const allMedia: TelegramMediaRef[] = []; const selection = new Map(); + let materializedCount = 0; let skippedCount = 0; for (const { ctx, msg } of entry.messages) { const sourceMessageId = String(msg.message_id); + const nativeKind = resolveTelegramPrimaryMedia(msg)?.kind ?? "document"; let media; try { media = await resolveMedia({ ctx, maxBytes: mediaMaxBytes, ...mediaRuntimeWithAbort }); @@ -241,6 +243,7 @@ export function createTelegramInboundMediaGroupRuntime( throw error; } runtime.log?.(warn(`media group: skipping photo that failed to fetch: ${String(error)}`)); + allMedia.push({ kind: nativeKind, sourceMessageId }); selection.set(sourceMessageId, "exclude"); skippedCount++; continue; @@ -249,11 +252,14 @@ export function createTelegramInboundMediaGroupRuntime( allMedia.push({ path: media.path, contentType: media.contentType, + kind: media.kind, stickerMetadata: media.stickerMetadata, sourceMessageId, }); + materializedCount++; selection.set(sourceMessageId, "include"); } else { + allMedia.push({ kind: nativeKind, sourceMessageId }); selection.set(sourceMessageId, "exclude"); skippedCount++; } @@ -266,7 +272,7 @@ export function createTelegramInboundMediaGroupRuntime( fn: () => bot.api.sendMessage( primary.msg.chat.id, - `⚠️ Received ${allMedia.length} of ${entry.messages.length} images — ${skippedCount} could not be fetched and ${verb} skipped.`, + `⚠️ Received ${materializedCount} of ${entry.messages.length} images — ${skippedCount} could not be fetched and ${verb} skipped.`, { reply_parameters: { message_id: primary.msg.message_id, diff --git a/extensions/telegram/src/bot-handlers.inbound.runtime.ts b/extensions/telegram/src/bot-handlers.inbound.runtime.ts index 640c0f7dd2c..a64e1f0874e 100644 --- a/extensions/telegram/src/bot-handlers.inbound.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound.runtime.ts @@ -7,7 +7,6 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import type { NormalizedAllowFrom } from "./bot-access.js"; import { @@ -33,7 +32,7 @@ import { recordTelegramMessageProcessingResult, } from "./bot-processing-outcome.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; -import { getTelegramTextParts } from "./bot/helpers.js"; +import { getTelegramTextParts, resolveTelegramPrimaryMedia } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js"; @@ -221,7 +220,8 @@ export function createTelegramHandlerInboundRuntime( return; } - let media: Awaited>; + const nativeMedia = resolveTelegramPrimaryMedia(msg); + let media: Awaited> = null; try { media = await resolveMedia({ ctx, @@ -229,6 +229,18 @@ export function createTelegramHandlerInboundRuntime( ...mediaRuntimeWithAbort, }); } catch (mediaErr) { + const replayingSpooledUpdate = isTelegramSpooledReplayUpdate(ctx.update); + if ( + mediaRuntimeWithAbort.abortSignal?.aborted && + isDurablyRetryableInboundMediaError(mediaErr) + ) { + // Abort mid-media-resolution must stay retryable for live updates too; + // a clean claim release would settle the update as handled and silently + // drop the message during shutdown or deadline cancellation. + recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr }); + releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr); + return; + } if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { const limitMb = @@ -248,15 +260,14 @@ export function createTelegramHandlerInboundRuntime( }).catch(() => {}); } logger.warn({ chatId, error: String(mediaErr) }, oversizeLogMessage); - releaseDispatchDedupeClaims(dispatchDedupeClaims); - return; - } - logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed"); - const retryable = isDurablyRetryableInboundMediaError(mediaErr); - if (retryable) { - recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr }); - } - if (!(retryable && isTelegramSpooledReplayUpdate(ctx.update))) { + } else { + logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed"); + const retryable = isDurablyRetryableInboundMediaError(mediaErr); + if (retryable && replayingSpooledUpdate) { + recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr }); + releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr); + return; + } await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, @@ -269,26 +280,18 @@ export function createTelegramHandlerInboundRuntime( }), }).catch(() => {}); } - releaseDispatchDedupeClaims(dispatchDedupeClaims, retryable ? mediaErr : undefined); - return; } - // Skip sticker-only messages where the sticker was skipped (animated/video) - // These have no media and no text content to process. - const hasText = Boolean(getTelegramTextParts(msg).text.trim()); - if (msg.sticker && !media && !hasText) { - logVerbose("telegram: skipping sticker-only message (unsupported sticker type)"); - releaseDispatchDedupeClaims(dispatchDedupeClaims); - return; - } - - const allMedia = media + const allMedia = nativeMedia ? [ - { - path: media.path, - contentType: media.contentType, - stickerMetadata: media.stickerMetadata, - }, + media + ? { + path: media.path, + contentType: media.contentType, + kind: media.kind, + stickerMetadata: media.stickerMetadata, + } + : { kind: nativeMedia.kind }, ] : []; const conversationKey = buildTelegramInboundDebounceConversationKey({ diff --git a/extensions/telegram/src/bot-handlers.message-context.runtime.ts b/extensions/telegram/src/bot-handlers.message-context.runtime.ts index 17a71f3a525..0f6f637d2bc 100644 --- a/extensions/telegram/src/bot-handlers.message-context.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-context.runtime.ts @@ -135,6 +135,7 @@ export function createTelegramMessageContextRuntime( return { ...entryWithoutProviderMediaRef, mediaPath: media.path, + mediaKind: media.kind, ...(media.contentType ? { mediaType: media.contentType } : {}), }; }; @@ -152,7 +153,7 @@ export function createTelegramMessageContextRuntime( sender_username: node.senderUsername, timestamp_ms: node.timestamp, body: node.body, - media_type: media?.contentType ?? node.mediaType, + media_type: media?.contentType ?? media?.kind ?? node.mediaType, media_path: media?.path, media_ref: media?.path ? undefined : node.mediaRef, reply_to_id: node.replyToId, diff --git a/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts b/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts index 3954f83e7cb..1909e7eb557 100644 --- a/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts @@ -1,5 +1,6 @@ // Telegram dispatch dedupe, replay settlement, and synthetic-message helpers. import type { Message } from "grammy/types"; +import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramAmbientTranscriptWatermark, @@ -15,7 +16,7 @@ import { import { buildSenderName, getTelegramTextParts, - resolveTelegramMediaPlaceholder, + resolveTelegramPrimaryMedia, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { @@ -161,8 +162,8 @@ export function createTelegramMessageLifecycleRuntime({ ): string | undefined => { const lines = messages.map((msg) => { const text = getTelegramTextParts(msg).text.trim(); - const body = - text || resolveTelegramMediaPlaceholder(msg) || "[User sent media without caption]"; + const media = resolveTelegramPrimaryMedia(msg); + const body = text || formatMediaPlaceholderText(media ? [{ kind: media.kind }] : [{}]); const messageId = msg.message_id ? `#${msg.message_id}` : undefined; const sender = buildSenderName(msg); const prefix = [messageId, sender].filter(Boolean).join(" "); diff --git a/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts b/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts new file mode 100644 index 00000000000..6edcec516d3 --- /dev/null +++ b/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts @@ -0,0 +1,45 @@ +import type { Message } from "grammy/types"; +import { describe, expect, it } from "vitest"; +import { createTelegramMessageLifecycleRuntime } from "./bot-handlers.message-lifecycle.runtime.js"; + +function message(fields: Record): Message { + return { + message_id: 1, + date: 1_700_000_000, + chat: { id: 42, type: "private", first_name: "Ada" }, + from: { id: 42, is_bot: false, first_name: "Ada" }, + ...fields, + } as unknown as Message; +} + +describe("Telegram ambient transcript media text", () => { + const runtime = createTelegramMessageLifecycleRuntime({ + accountId: "default", + runtime: { log: () => {}, error: () => {}, exit: () => {} } as never, + }); + + it("renders native media kinds for captionless transcript lines", () => { + const body = runtime.formatTelegramAmbientTranscriptBody([ + message({ + message_id: 7, + photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }], + }), + ]); + + expect(body).toBe("#7 Ada: "); + }); + + it("preserves captions instead of appending media text", () => { + const body = runtime.formatTelegramAmbientTranscriptBody([ + message({ message_id: 8, caption: "diagram", document: { file_id: "doc-1" } }), + ]); + + expect(body).toBe("#8 Ada: diagram"); + }); + + it("uses the formatter attachment fallback for media-less empty messages", () => { + const body = runtime.formatTelegramAmbientTranscriptBody([message({ message_id: 9 })]); + + expect(body).toBe("#9 Ada: "); + }); +}); diff --git a/extensions/telegram/src/bot-handlers.message.runtime.ts b/extensions/telegram/src/bot-handlers.message.runtime.ts index a52c7480bcf..479235d76b4 100644 --- a/extensions/telegram/src/bot-handlers.message.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message.runtime.ts @@ -1,6 +1,7 @@ // Telegram message/session/prompt pipeline shared by bot handler registrars. import type { Message } from "grammy/types"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; +import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; @@ -130,6 +131,7 @@ export function createTelegramHandlerMessageRuntime({ mediaRef = media ? { path: media.path, + kind: media.kind, ...(media.contentType ? { contentType: media.contentType } : {}), ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), } @@ -340,6 +342,7 @@ export function createTelegramHandlerMessageRuntime({ if (entry.messageId && entry.mediaPath && promptMediaPath) { promptContextMediaByMessageId.set(entry.messageId, { path: promptMediaPath, + kind: entry.mediaKind ?? kindFromMime(entry.mediaType) ?? "document", ...(entry.mediaType ? { contentType: entry.mediaType } : {}), }); } diff --git a/extensions/telegram/src/bot-message-context.audio-transcript.test-support.ts b/extensions/telegram/src/bot-message-context.audio-transcript.test-support.ts index 91bcc187a3f..6cee1ea7f61 100644 --- a/extensions/telegram/src/bot-message-context.audio-transcript.test-support.ts +++ b/extensions/telegram/src/bot-message-context.audio-transcript.test-support.ts @@ -45,7 +45,7 @@ async function buildGroupVoiceContext(params: { from: { id: params.fromId, first_name: params.firstName }, voice: { file_id: params.fileId }, }, - allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }], + allMedia: [{ path: params.mediaPath, contentType: "audio/ogg", kind: "audio" }], options: { forceWasMentioned: true }, cfg: { agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } }, @@ -73,9 +73,11 @@ function expectTranscriptRendered( expect(ctx?.ctxPayload?.MediaTranscribedIndexes).toEqual([0]); } -function expectAudioPlaceholderRendered(ctx: Awaited>) { +function expectAudioFactWithEmptyBody(ctx: Awaited>) { expect(ctx).not.toBeNull(); - expect(ctx?.ctxPayload?.Body).toContain(""); + expect(ctx?.ctxPayload?.BodyForAgent).toBe(""); + expect(ctx?.ctxPayload?.RawBody).toBe(""); + expect(ctx?.ctxPayload?.MediaTypes).toEqual(["audio/ogg"]); } describe("buildTelegramMessageContext audio transcript body", () => { @@ -117,7 +119,7 @@ describe("buildTelegramMessageContext audio transcript body", () => { }); expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - expectAudioPlaceholderRendered(ctx); + expectAudioFactWithEmptyBody(ctx); }); it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => { @@ -157,6 +159,6 @@ describe("buildTelegramMessageContext audio transcript body", () => { }); expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - expectAudioPlaceholderRendered(ctx); + expectAudioFactWithEmptyBody(ctx); }); }); diff --git a/extensions/telegram/src/bot-message-context.body.test.ts b/extensions/telegram/src/bot-message-context.body.test.ts index 921027b9011..a104a3b4baf 100644 --- a/extensions/telegram/src/bot-message-context.body.test.ts +++ b/extensions/telegram/src/bot-message-context.body.test.ts @@ -357,7 +357,7 @@ describe("resolveTelegramInboundBody", () => { expect(result?.bodyText).toBe("Hello **world** [docs](https://docs.example)"); }); - it("keeps the media marker when a captioned video has no downloaded media", async () => { + it("keeps only the caption when a video has no downloaded media", async () => { const result = await resolveTelegramBody({ msg: { message_id: 0, @@ -376,10 +376,10 @@ describe("resolveTelegramInboundBody", () => { }); expect(result?.rawBody).toBe("episode caption"); - expect(result?.bodyText).toBe(" [file_id:video-1]\nepisode caption"); + expect(result?.bodyText).toBe("episode caption"); }); - it("uses saved media MIME for no-caption photo placeholders", async () => { + it("keeps no-caption photo bodies empty after materialization", async () => { const result = await resolveTelegramBody({ msg: { message_id: 3, @@ -388,14 +388,16 @@ describe("resolveTelegramInboundBody", () => { from: { id: 42, first_name: "Pat" }, photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 120, height: 80 }], } as never, - allMedia: [{ path: "/tmp/upload.bin", contentType: "application/octet-stream" }], + allMedia: [ + { path: "/tmp/upload.bin", contentType: "application/octet-stream", kind: "image" }, + ], }); - expect(result?.rawBody).toBe(""); - expect(result?.bodyText).toBe(""); + expect(result?.rawBody).toBe(""); + expect(result?.bodyText).toBe(""); }); - it("summarizes multiple saved images as images", async () => { + it("keeps aggregate image bodies empty", async () => { const result = await resolveTelegramBody({ msg: { message_id: 4, @@ -405,15 +407,15 @@ describe("resolveTelegramInboundBody", () => { photo: [{ file_id: "photo-2", file_unique_id: "photo-u2", width: 120, height: 80 }], } as never, allMedia: [ - { path: "/tmp/photo-1.webp", contentType: "image/webp" }, - { path: "/tmp/photo-2.png", contentType: "image/png" }, + { path: "/tmp/photo-1.webp", contentType: "image/webp", kind: "image" }, + { path: "/tmp/photo-2.png", contentType: "image/png", kind: "image" }, ], }); - expect(result?.bodyText).toBe(" (2 images)"); + expect(result?.bodyText).toBe(""); }); - it("summarizes mixed saved media as attachments", async () => { + it("keeps mixed aggregate media bodies empty", async () => { const result = await resolveTelegramBody({ msg: { message_id: 5, @@ -423,12 +425,12 @@ describe("resolveTelegramInboundBody", () => { photo: [{ file_id: "photo-3", file_unique_id: "photo-u3", width: 120, height: 80 }], } as never, allMedia: [ - { path: "/tmp/photo.webp", contentType: "image/webp" }, - { path: "/tmp/report.pdf", contentType: "application/pdf" }, + { path: "/tmp/photo.webp", contentType: "image/webp", kind: "image" }, + { path: "/tmp/report.pdf", contentType: "application/pdf", kind: "document" }, ], }); - expect(result?.bodyText).toBe(" (2 attachments)"); + expect(result?.bodyText).toBe(""); }); it("preserves cached sticker descriptions when downloaded media exists", async () => { @@ -454,6 +456,7 @@ describe("resolveTelegramInboundBody", () => { { path: "/tmp/sticker.webp", contentType: "image/webp", + kind: "sticker", stickerMetadata: { emoji: "ok", setName: "test-set", @@ -489,6 +492,7 @@ describe("resolveTelegramInboundBody", () => { { path: "/tmp/sticker.webp", contentType: "image/webp", + kind: "sticker", stickerMetadata: { cachedDescription: "Cached description" }, }, ], @@ -521,12 +525,13 @@ describe("resolveTelegramInboundBody", () => { { path: "/tmp/sticker.webp", contentType: "image/webp", + kind: "sticker", stickerMetadata: { cachedDescription: "Cached description" }, }, ], }); - expect(result?.bodyText).toBe(""); + expect(result?.bodyText).toBe(""); expect(result?.stickerCacheHit).toBe(false); }); @@ -546,7 +551,7 @@ describe("resolveTelegramInboundBody", () => { photo: [{ file_id: "photo-4", file_unique_id: "photo-u4", width: 120, height: 80 }], entities: [], } as never, - allMedia: [{ path: "/tmp/photo.webp", contentType: "image/webp" }], + allMedia: [{ path: "/tmp/photo.webp", contentType: "image/webp", kind: "image" }], isGroup: true, chatId: -1001234567890, senderId: "46", @@ -557,8 +562,8 @@ describe("resolveTelegramInboundBody", () => { }); expect(logger.info).not.toHaveBeenCalled(); - expect(result?.rawBody).toBe(""); - expect(result?.bodyText).toBe(""); + expect(result?.rawBody).toBe(""); + expect(result?.bodyText).toBe(""); expect(result?.effectiveWasMentioned).toBe(true); }); @@ -578,7 +583,7 @@ describe("resolveTelegramInboundBody", () => { photo: [{ file_id: "photo-5", file_unique_id: "photo-u5", width: 120, height: 80 }], entities: [], } as never, - allMedia: [{ path: "/tmp/photo.webp", contentType: "image/webp" }], + allMedia: [{ path: "/tmp/photo.webp", contentType: "image/webp", kind: "image" }], isGroup: true, chatId: -1001234567890, senderId: "46", @@ -643,7 +648,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-1" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg", kind: "audio" }], isGroup: true, chatId: -1001234567890, senderId: "46", @@ -683,7 +688,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-2" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice-2.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice-2.ogg", contentType: "audio/ogg", kind: "audio" }], isGroup: true, chatId: -1001234567891, senderId: "46", @@ -720,7 +725,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-dm-1" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice-dm.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice-dm.ogg", contentType: "audio/ogg", kind: "audio" }], }); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); @@ -755,7 +760,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-dm-topic-1" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice-dm-topic.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice-dm-topic.ogg", contentType: "audio/ogg", kind: "audio" }], replyThreadId: 77, }); @@ -785,7 +790,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-forum-topic-1" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice-forum-topic.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice-forum-topic.ogg", contentType: "audio/ogg", kind: "audio" }], isGroup: true, chatId: -1001234567890, senderId: "46", @@ -869,7 +874,7 @@ describe("resolveTelegramInboundBody", () => { voice: { file_id: "voice-escape" }, entities: [], } as never, - allMedia: [{ path: "/tmp/voice-escape.ogg", contentType: "audio/ogg" }], + allMedia: [{ path: "/tmp/voice-escape.ogg", contentType: "audio/ogg", kind: "audio" }], isGroup: true, chatId: -1001234567892, senderId: "46", diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 957f10afa81..f86ac4bc064 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -2,6 +2,7 @@ import { buildMentionRegexes, classifyChannelInboundEvent, + formatMediaPlaceholderText, formatLocationText, implicitMentionKindWhen, logInboundDrop, @@ -88,44 +89,6 @@ function formatAudioTranscriptForAgent(transcript: string): string { return `[Audio transcript (machine-generated, untrusted)]: ${JSON.stringify(transcript)}`; } -type TelegramSavedMediaKind = "audio" | "document" | "image" | "video"; - -function resolveSavedMediaKind(contentType: string | undefined): TelegramSavedMediaKind { - const normalized = contentType?.split(";")[0]?.trim().toLowerCase(); - if (normalized?.startsWith("audio/")) { - return "audio"; - } - if (normalized?.startsWith("image/")) { - return "image"; - } - if (normalized?.startsWith("video/")) { - return "video"; - } - return "document"; -} - -function formatSavedMediaPlaceholder(allMedia: TelegramMediaRef[]): string | undefined { - if (allMedia.length === 0) { - return undefined; - } - const kinds = allMedia.map((media) => resolveSavedMediaKind(media.contentType)); - const firstKind = kinds[0] ?? "document"; - const kind = kinds.every((candidate) => candidate === firstKind) ? firstKind : "document"; - if (allMedia.length === 1) { - return ``; - } - if (kind === "image") { - return ` (${allMedia.length} images)`; - } - if (kind === "video") { - return ` (${allMedia.length} videos)`; - } - if (kind === "audio") { - return ` (${allMedia.length} audio attachments)`; - } - return ` (${allMedia.length} attachments)`; -} - function resolveTelegramMentionFacts(params: { canDetectMention: boolean; effectiveWasMentioned: boolean; @@ -255,17 +218,20 @@ export async function resolveTelegramInboundBody(params: { const originatingTo = providedOriginatingTo ?? buildTelegramInboundOriginTarget(chatId); const primaryMedia = resolveTelegramPrimaryMedia(msg); - let placeholder = primaryMedia?.placeholder ?? ""; + const nativeMediaFacts = + allMedia.length > 0 ? allMedia : primaryMedia ? [{ kind: primaryMedia.kind }] : []; const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription; - const stickerSupportsVision = msg.sticker - ? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId }) - : false; + const stickerSupportsVision = + msg.sticker && allMedia.some((media) => media.kind === "sticker" && media.path) + ? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId }) + : false; const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision; + let formattedStickerDescription: string | undefined; if (stickerCacheHit) { const emoji = allMedia[0]?.stickerMetadata?.emoji; const setName = allMedia[0]?.stickerMetadata?.setName; const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" "); - placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`; + formattedStickerDescription = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`; } const locationData = extractTelegramLocation(msg); @@ -278,23 +244,23 @@ export async function resolveTelegramInboundBody(params: { const hasUserText = Boolean(rawText || locationText); let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); if (!rawBody) { - rawBody = richText ?? resolveTelegramRichMessagePlaceholder(msg) ?? placeholder; + rawBody = richText ?? resolveTelegramRichMessagePlaceholder(msg) ?? ""; } - if (!rawBody && allMedia.length === 0) { + if (!rawBody && nativeMediaFacts.length === 0) { return null; } let bodyText = rawBody; - if (stickerCacheHit && placeholder && rawBody !== placeholder) { - bodyText = `${placeholder}\n${bodyText}`.trim(); + if (formattedStickerDescription) { + bodyText = [formattedStickerDescription, rawBody].filter(Boolean).join("\n"); } - if (allMedia.length === 0 && placeholder && rawBody !== placeholder) { - const mediaTag = primaryMedia?.fileRef.file_id - ? `${placeholder} [file_id:${primaryMedia.fileRef.file_id}]` - : placeholder; - bodyText = `${mediaTag}\n${bodyText}`.trim(); - } - const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/")); + const isAudioMedia = (media: TelegramMediaRef) => + media.kind === "audio" || media.contentType?.startsWith("audio/") === true; + const hasAudio = nativeMediaFacts.some(isAudioMedia); + const materializedMedia = allMedia.filter((media) => Boolean(media.path)); + const materializedAudioIndex = allMedia.findIndex( + (media) => Boolean(media.path) && isAudioMedia(media), + ); const disableAudioPreflight = (topicConfig?.disableAudioPreflight ?? (groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true; @@ -304,6 +270,7 @@ export async function resolveTelegramInboundBody(params: { let preflightTranscript: string | undefined; const needsPreflightTranscription = hasAudio && + materializedAudioIndex >= 0 && !hasUserText && (!isGroup || (requireMention && @@ -321,10 +288,13 @@ export async function resolveTelegramInboundBody(params: { OriginatingTo: originatingTo, AccountId: accountId, MessageThreadId: replyThreadId, - MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaPaths: + materializedMedia.length > 0 + ? materializedMedia.map((media) => media.path as string) + : undefined, MediaTypes: - allMedia.length > 0 - ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) + materializedMedia.length > 0 + ? materializedMedia.map((media) => media.contentType ?? media.kind) : undefined, }; preflightTranscript = await transcribeFirstAudio({ @@ -337,33 +307,13 @@ export async function resolveTelegramInboundBody(params: { } } const audioTranscribedMediaIndex = - preflightTranscript === undefined - ? undefined - : allMedia.findIndex((media) => media.contentType?.startsWith("audio/")); + preflightTranscript === undefined ? undefined : materializedAudioIndex; - if (hasAudio && bodyText === "" && preflightTranscript) { + if (hasAudio && !rawBody && preflightTranscript) { bodyText = formatAudioTranscriptForAgent(preflightTranscript); } - - const savedMediaPlaceholder = formatSavedMediaPlaceholder(allMedia); - if ( - !stickerCacheHit && - !hasAudio && - savedMediaPlaceholder && - placeholder && - bodyText === placeholder - ) { - bodyText = savedMediaPlaceholder; - } - if (!bodyText && allMedia.length > 0) { - if (hasAudio) { - bodyText = preflightTranscript - ? formatAudioTranscriptForAgent(preflightTranscript) - : ""; - } else { - bodyText = savedMediaPlaceholder ?? ""; - } - } + const historyBody = + rawBody || formattedStickerDescription || formatMediaPlaceholderText(nativeMediaFacts); const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention"); const explicitlyMentioned = botUsername @@ -440,7 +390,7 @@ export async function resolveTelegramInboundBody(params: { limit: historyLimit, entry: { sender: buildSenderLabel(msg, senderId || chatId), - body: rawBody, + body: historyBody, timestamp: msg.date ? msg.date * 1000 : undefined, messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, }, diff --git a/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts b/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts index f4cff712644..b366d9a4d8f 100644 --- a/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts +++ b/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts @@ -123,7 +123,7 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => { from: sender, text: "ordinary note", }, - allMedia: [{ path: "/tmp/photo-1.jpg", contentType: "image/jpeg" }], + allMedia: [{ path: "/tmp/photo-1.jpg", contentType: "image/jpeg", kind: "image" }], options: { inboundDebounceMessages: [ { diff --git a/extensions/telegram/src/bot-message-context.media-carriers.test.ts b/extensions/telegram/src/bot-message-context.media-carriers.test.ts new file mode 100644 index 00000000000..2a2f92ab4b4 --- /dev/null +++ b/extensions/telegram/src/bot-message-context.media-carriers.test.ts @@ -0,0 +1,188 @@ +import type { Message } from "grammy/types"; +import { describe, expect, it, vi } from "vitest"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; +import { describeReplyTarget } from "./bot/helpers.js"; + +vi.mock("./sticker-vision.runtime.js", () => ({ + resolveStickerVisionSupportRuntime: vi.fn(async () => false), +})); + +describe("buildTelegramMessageContext media carriers", () => { + it("keeps reply media structured before reply-chain rendering", () => { + const target = describeReplyTarget({ + message_id: 11, + date: 1_700_000_000, + chat: { id: 42, type: "private", first_name: "Ada" }, + from: { id: 42, is_bot: false, first_name: "Ada" }, + reply_to_message: { + message_id: 10, + date: 1_699_999_999, + chat: { id: 42, type: "private", first_name: "Pat" }, + from: { id: 7, is_bot: false, first_name: "Pat" }, + photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }], + }, + } as unknown as Message); + + expect(target).toMatchObject({ mediaType: "image", sender: "Pat" }); + expect(target?.body).toBeUndefined(); + }); + + it("renders cached native media kinds in reply-chain text", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + text: "What was that?", + }, + replyChain: [ + { + messageId: "9", + sender: "Pat", + mediaType: "image", + }, + ], + }); + + expect(context?.ctxPayload.Body).toContain("[Reply chain - nearest first]"); + expect(context?.ctxPayload.Body).toContain(""); + }); + + it("keeps native sticker kind ahead of its materialized image MIME", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + text: "What was that?", + }, + replyChain: [ + { + messageId: "10", + sender: "Pat", + mediaKind: "sticker", + mediaType: "image/webp", + }, + ], + }); + + expect(context?.ctxPayload.Body).toContain(""); + expect(context?.ctxPayload.Body).not.toContain(""); + }); + + it("uses only the immediate reply media for ReplyToBody", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + text: "What was that?", + }, + replyChain: [ + { messageId: "10", sender: "Pat", mediaType: "image", replyToId: "9" }, + { messageId: "9", sender: "Sam", mediaType: "document" }, + ], + }); + + expect(context?.ctxPayload.ReplyToBody).toBe(""); + expect(context?.ctxPayload.Body).toContain(""); + }); + + it("keeps the native reply kind when a cached chain is filtered out", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: -1001, type: "supergroup", title: "Ops" }, + from: { id: 1, is_bot: false, first_name: "Ada" }, + text: "What was that?", + reply_to_message: { + message_id: 10, + date: 1_699_999_999, + chat: { id: -1001, type: "supergroup", title: "Ops" }, + from: { id: 1, is_bot: false, first_name: "Ada" }, + photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }], + }, + }, + cfg: { + channels: { telegram: { groupPolicy: "allowlist", contextVisibility: "allowlist" } }, + }, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false, allowFrom: ["1"] }, + topicConfig: undefined, + }), + replyChain: [{ messageId: "10", sender: "Hidden", senderId: "999", mediaType: "image" }], + }); + + expect(context?.ctxPayload.ReplyToBody).toBe(""); + expect(context?.ctxPayload.MediaTypes).toEqual(["image"]); + }); + + it("keeps primary media bodies empty while recording formatted group history", async () => { + const groupHistories = new Map(); + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: -1001, type: "supergroup", title: "Ops" }, + text: undefined, + photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }], + }, + allMedia: [{ kind: "image" }], + groupHistories, + historyLimit: 5, + }); + + expect(context?.ctxPayload.RawBody).toBe(""); + expect(context?.ctxPayload.BodyForAgent).toBe(""); + expect(context?.ctxPayload.CommandBody).toBe(""); + expect(context?.ctxPayload.CommandSource).toBeUndefined(); + expect(context?.ctxPayload.MediaTypes).toEqual(["image"]); + expect([...groupHistories.values()].flat().at(-1)?.body).toBe(""); + }); + + it("admits an unavailable native sticker as a type-only fact", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + text: undefined, + sticker: { + file_id: "sticker-1", + file_unique_id: "sticker-u1", + type: "regular", + width: 1, + height: 1, + is_animated: true, + is_video: false, + }, + }, + allMedia: [{ kind: "sticker" }], + }); + + expect(context?.ctxPayload.RawBody).toBe(""); + expect(context?.ctxPayload.BodyForAgent).toBe(""); + expect(context?.ctxPayload.MediaTypes).toEqual(["sticker"]); + expect(context?.ctxPayload.StickerMediaIncluded).toBeUndefined(); + }); + + it("preserves cached sticker descriptions in group history", async () => { + const groupHistories = new Map(); + await buildTelegramMessageContextForTest({ + message: { + chat: { id: -1002, type: "supergroup", title: "Stickers" }, + text: undefined, + sticker: { + file_id: "sticker-2", + file_unique_id: "sticker-u2", + type: "regular", + width: 1, + height: 1, + is_animated: false, + is_video: false, + }, + }, + allMedia: [ + { + kind: "sticker", + path: "/tmp/sticker.webp", + contentType: "image/webp", + stickerMetadata: { cachedDescription: "A waving sticker" }, + }, + ], + groupHistories, + historyLimit: 5, + }); + + expect([...groupHistories.values()].flat().at(-1)?.body).toBe("[Sticker] A waving sticker"); + }); +}); diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index e244ff77a3f..bdcfbd92741 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -3,6 +3,7 @@ import { type BuildChannelInboundEventContextParams, type BuildChannelInboundEventContextAsyncParams, type BuiltChannelInboundEventContext, + formatMediaPlaceholderText, formatInboundEnvelope, resolveEnvelopeFormatOptions, toLocationContext, @@ -46,7 +47,8 @@ import { describeReplyTarget, getTelegramTextParts, normalizeForwardedContext, - resolveTelegramMediaPlaceholder, + resolveTelegramPrimaryMedia, + type TelegramMediaKind, type TelegramReplyTarget, type TelegramThreadSpec, } from "./bot/helpers.js"; @@ -125,6 +127,9 @@ function replyTargetToChainEntry(replyTarget: TelegramReplyTarget): TelegramRepl ...(replyTarget.senderId ? { senderId: replyTarget.senderId } : {}), ...(replyTarget.senderUsername ? { senderUsername: replyTarget.senderUsername } : {}), ...(replyTarget.body ? { body: replyTarget.body } : {}), + ...(replyTarget.mediaType + ? { mediaKind: replyTarget.mediaType, mediaType: replyTarget.mediaType } + : {}), ...(replyTarget.kind === "quote" ? { isQuote: true } : {}), ...(replyTarget.forwardedFrom?.from ? { forwardedFrom: replyTarget.forwardedFrom.from } : {}), ...(replyTarget.forwardedFrom?.fromId @@ -176,13 +181,33 @@ function formatReplyChainEntry(entry: TelegramReplyChainEntry, index: number): s forwardedFrom: entry.forwardedFrom, forwardedDate: entry.forwardedDate, }), - entry.mediaType ? `` : undefined, + entry.mediaKind || entry.mediaType + ? formatMediaPlaceholderText([ + entry.mediaKind + ? { kind: entry.mediaKind } + : isTelegramMediaKind(entry.mediaType ?? "") + ? { kind: entry.mediaType as TelegramMediaKind } + : { contentType: entry.mediaType }, + ]) + : undefined, mediaPath ? `[media_path:${mediaPath}]` : undefined, entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined, ].filter(Boolean); return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`; } +const TELEGRAM_MEDIA_KINDS = new Set([ + "audio", + "document", + "image", + "sticker", + "video", +]); + +function isTelegramMediaKind(value: string): value is TelegramMediaKind { + return TELEGRAM_MEDIA_KINDS.has(value as TelegramMediaKind); +} + export async function buildTelegramInboundContextPayload(params: { cfg: OpenClawConfig; primaryCtx: TelegramContext; @@ -371,9 +396,10 @@ export async function buildTelegramInboundContextPayload(params: { const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null; const inboundDebounceBodySegments = hasMultiMessageDebounceBatch ? options?.inboundDebounceMessages?.flatMap((debouncedMessage) => { + const debouncedMedia = resolveTelegramPrimaryMedia(debouncedMessage); const segmentBody = getTelegramTextParts(debouncedMessage).text || - resolveTelegramMediaPlaceholder(debouncedMessage); + formatMediaPlaceholderText(debouncedMedia ? [{ kind: debouncedMedia.kind }] : []); if (!segmentBody) { return []; } @@ -506,22 +532,47 @@ export async function buildTelegramInboundContextPayload(params: { }); const replyHead = visibleReplyChain[0]; const toInboundMedia = (media: TelegramMediaRef, index?: number) => ({ - path: media.path, - url: media.path, + ...(media.path ? { path: media.path, url: media.path } : {}), contentType: media.contentType, + kind: media.kind, transcribed: index !== undefined && audioTranscribedMediaIndex === index, }); const currentMediaFacts = allMedia.map(toInboundMedia); + const toReplyChainMediaFact = (entry: TelegramReplyChainEntry) => + entry.mediaPath || entry.mediaKind || entry.mediaType + ? { + ...(entry.mediaPath ? { path: entry.mediaPath, url: entry.mediaPath } : {}), + ...(entry.mediaKind ? { kind: entry.mediaKind } : {}), + ...(entry.mediaType + ? isTelegramMediaKind(entry.mediaType) + ? entry.mediaKind + ? {} + : { kind: entry.mediaType } + : { contentType: entry.mediaType } + : {}), + } + : undefined; const replyMediaFacts = visibleReplyChain.length > 0 - ? visibleReplyChain.flatMap((entry) => - entry.mediaPath - ? [{ path: entry.mediaPath, url: entry.mediaPath, contentType: entry.mediaType }] - : [], - ) + ? visibleReplyChain.flatMap((entry) => { + const media = toReplyChainMediaFact(entry); + return media ? [media] : []; + }) : visibleReplyTarget - ? replyMedia.map((media) => toInboundMedia(media)) + ? replyMedia.length > 0 + ? replyMedia.map((media) => toInboundMedia(media)) + : visibleReplyTarget.mediaType + ? [{ kind: visibleReplyTarget.mediaType }] + : [] : []; + const replyHeadMedia = replyHead ? toReplyChainMediaFact(replyHead) : undefined; + const replyTargetMedia = + replyHeadMedia ?? + (visibleReplyTarget?.mediaType ? { kind: visibleReplyTarget.mediaType } : undefined); + const replyBody = + replyHead?.body ?? + visibleReplyTarget?.body ?? + (replyTargetMedia ? formatMediaPlaceholderText([replyTargetMedia]) : undefined); const telegramFrom = isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`; @@ -599,7 +650,7 @@ export async function buildTelegramInboundContextPayload(params: { replyHead || visibleReplyTarget ? { id: replyHead?.messageId ?? visibleReplyTarget?.id, - body: replyHead?.body ?? visibleReplyTarget?.body, + body: replyBody, sender: replyHead?.sender ?? visibleReplyTarget?.sender, senderAllowed: true, isQuote: @@ -672,7 +723,10 @@ export async function buildTelegramInboundContextPayload(params: { limit: historyLimit, entry: { sender: buildSenderLabel(msg, senderId || chatId), - body: rawBody, + body: + rawBody || + (stickerCacheHit ? bodyText : undefined) || + formatMediaPlaceholderText(currentMediaFacts), timestamp: msg.date ? msg.date * 1000 : undefined, messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, }, diff --git a/extensions/telegram/src/bot-message-context.sticker-media.test.ts b/extensions/telegram/src/bot-message-context.sticker-media.test.ts index 737f2201bab..4c530a35e2b 100644 --- a/extensions/telegram/src/bot-message-context.sticker-media.test.ts +++ b/extensions/telegram/src/bot-message-context.sticker-media.test.ts @@ -61,6 +61,7 @@ describe("buildTelegramMessageContext sticker media", () => { { path: stickerPath, contentType: "image/webp", + kind: "sticker", stickerMetadata: { emoji: "🔥", setName: "NewSet", diff --git a/extensions/telegram/src/bot-message-context.test-harness.ts b/extensions/telegram/src/bot-message-context.test-harness.ts index 8210b18bd1e..0144f8f882e 100644 --- a/extensions/telegram/src/bot-message-context.test-harness.ts +++ b/extensions/telegram/src/bot-message-context.test-harness.ts @@ -25,6 +25,7 @@ type BuildTelegramMessageContextForTestParams = { message: Record; me?: Record; allMedia?: TelegramMediaRef[]; + replyChain?: BuildTelegramMessageContextParams["replyChain"]; promptContext?: BuildTelegramMessageContextParams["promptContext"]; options?: BuildTelegramMessageContextParams["options"]; cfg?: Record; @@ -128,6 +129,7 @@ export async function buildTelegramMessageContextForTest( me: { id: 7, username: "bot", ...params.me }, } as never, allMedia: params.allMedia ?? [], + replyChain: params.replyChain ?? [], promptContext: params.promptContext ?? [], storeAllowFrom: [], options: params.options ?? {}, diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index b8b1441946f..8a7db77af4f 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -10,12 +10,14 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import type { TelegramMediaKind } from "./bot/body-helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; import type { TelegramReplyChainEntry } from "./message-cache.js"; import type { TelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; export type TelegramMediaRef = { - path: string; + kind: TelegramMediaKind; + path?: string; contentType?: string; stickerMetadata?: StickerMetadata; sourceMessageId?: string; diff --git a/extensions/telegram/src/bot-message-dispatch.context-recovery.test.ts b/extensions/telegram/src/bot-message-dispatch.context-recovery.test.ts index 695595d90eb..f0cedb37ec5 100644 --- a/extensions/telegram/src/bot-message-dispatch.context-recovery.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.context-recovery.test.ts @@ -51,6 +51,7 @@ describeTelegramDispatch("dispatchTelegramMessage context-recovery", () => { const ctxPayload = { Body: body, BodyForAgent: body, + RawBody: "What is this?", MediaPath: "/tmp/sticker.webp", Sticker: { fileId: "sticker-file", @@ -76,6 +77,26 @@ describeTelegramDispatch("dispatchTelegramMessage context-recovery", () => { }); }); + it("preserves supplemental context when describing a captionless sticker", async () => { + describeStickerImage.mockResolvedValueOnce("A contextual sticker"); + const ctxPayload = { + Body: "reply-chain context", + BodyForAgent: "reply-chain context", + RawBody: "", + MediaPath: "/tmp/sticker.webp", + Sticker: { + fileId: "sticker-file", + fileUniqueId: "sticker-unique", + }, + StickerMediaIncluded: true, + } as TelegramMessageContext["ctxPayload"]; + + await dispatchWithContext({ context: createContext({ ctxPayload }) }); + + expect(ctxPayload.Body).toBe("[Sticker] A contextual sticker\nreply-chain context"); + expect(ctxPayload.BodyForAgent).toBe("[Sticker] A contextual sticker\nreply-chain context"); + }); + it("streams drafts in private threads and forwards thread id", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index eae5c642258..c8cd03f5cc6 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -65,20 +65,23 @@ async function resolveStickerVisionSupport( } } -function includeStickerDescription(body: string | undefined, formattedDescription: string): string { - if (!body) { - return formattedDescription; +function includeStickerDescription(params: { + body: string | undefined; + formattedDescription: string; +}): string { + if (!params.body) { + return params.formattedDescription; } - const current = body.trim(); - if (!current || current === "") { - return formattedDescription; + const current = params.body.trim(); + if (!current) { + return params.formattedDescription; } // Cached descriptions can already be present from inbound context construction. // Keep that body intact so captions, forwarded text, and supplemental context survive. - if (body.includes(formattedDescription)) { - return body; + if (params.body.includes(params.formattedDescription)) { + return params.body; } - return `${formattedDescription}\n${body}`; + return `${params.formattedDescription}\n${params.body}`; } function resolveTelegramQuoteContext(params: { @@ -187,14 +190,19 @@ async function prepareTelegramSticker(params: { const formattedDescription = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; sticker.cachedDescription = description; if (!stickerSupportsVision) { - context.ctxPayload.Body = includeStickerDescription( - context.ctxPayload.Body, + const isCaptionlessSticker = + !context.ctxPayload.RawBody?.trim() && context.ctxPayload.StickerMediaIncluded === true; + context.ctxPayload.Body = includeStickerDescription({ + body: context.ctxPayload.Body, formattedDescription, - ); - context.ctxPayload.BodyForAgent = includeStickerDescription( - context.ctxPayload.BodyForAgent, - formattedDescription, - ); + }); + context.ctxPayload.BodyForAgent = + isCaptionlessSticker && !context.ctxPayload.BodyForAgent?.trim() + ? formattedDescription + : includeStickerDescription({ + body: context.ctxPayload.BodyForAgent, + formattedDescription, + }); context.ctxPayload.SkipStickerMediaUnderstanding = true; } cacheSticker({ diff --git a/extensions/telegram/src/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts index caeac1a982e..42361c652ee 100644 --- a/extensions/telegram/src/bot-message.test.ts +++ b/extensions/telegram/src/bot-message.test.ts @@ -295,7 +295,7 @@ describe("telegram bot message processor", () => { const processMessage = createTelegramMessageProcessor(baseDeps); await expect( processSampleMessage(processMessage, undefined, {}, {}, [ - { path: "/tmp/photo.jpg", contentType: "image/jpeg" }, + { path: "/tmp/photo.jpg", contentType: "image/jpeg", kind: "image" }, ]), ).resolves.toEqual({ kind: "completed" }); diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index e1a64a39434..8f33fed41c2 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -254,7 +254,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep : context.ctxPayload.To, chatType: context.ctxPayload.ChatType, body: context.ctxPayload.RawBody, - mediaType: allMedia[0]?.contentType, + mediaType: allMedia[0]?.contentType ?? allMedia[0]?.kind, }), ); const spooledReplay = diff --git a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts index 320a5d5356e..83d47a95058 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts @@ -231,6 +231,17 @@ function replyPayload(): Record { return call[0] as Record; } +function expectTypeOnlyMediaPayload(kind: string, rawBody = "") { + const payload = replyPayload(); + expect(payload).toMatchObject({ + BodyForAgent: rawBody, + MediaType: kind, + MediaTypes: [kind], + RawBody: rawBody, + }); + expect(payload.MediaPaths).toBeUndefined(); +} + describe("createTelegramBot channel_post media", () => { beforeAll(() => { createTelegramBot = (opts) => @@ -334,7 +345,7 @@ describe("createTelegramBot channel_post media", () => { } }); - it("drops oversized channel_post media instead of dispatching a placeholder message", async () => { + it("dispatches an oversized channel_post as a type-only media fact", async () => { setOpenChannelPostConfig(); const fetchSpy = createImageFetchSpy({ @@ -347,13 +358,14 @@ describe("createTelegramBot channel_post media", () => { await handler( createChannelPostContext({ - messageId: 401, + messageId: 4001, date: 1736380800, photoFileId: "oversized", }), ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image"); fetchSpy.mockRestore(); }); @@ -397,13 +409,14 @@ describe("createTelegramBot channel_post media", () => { }, }, ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image"); } finally { fetchSpy.mockRestore(); } }); - it("warns instead of dispatching a placeholder when Telegram getFile fails (#100000)", async () => { + it("warns and dispatches a type-only fact when Telegram getFile fails (#100000)", async () => { loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }); @@ -435,7 +448,8 @@ describe("createTelegramBot channel_post media", () => { reply_parameters: expect.objectContaining({ message_id: 100000 }), }), ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("document"); expect(saveRemoteMedia).not.toHaveBeenCalled(); }); @@ -454,10 +468,11 @@ describe("createTelegramBot channel_post media", () => { createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; + const messageId = 100001 + expectedLimitMb; await handler({ message: { chat: { id: 1234, type: "private" }, - message_id: 100001, + message_id: messageId, date: 1736380800, document: { file_id: "doc-100001", file_name: "large.bin" }, from: { id: 55, is_bot: false, first_name: "u" }, @@ -473,10 +488,11 @@ describe("createTelegramBot channel_post media", () => { 1234, `⚠️ File too large. Maximum size is ${expectedLimitMb}MB.`, expect.objectContaining({ - reply_parameters: expect.objectContaining({ message_id: 100001 }), + reply_parameters: expect.objectContaining({ message_id: messageId }), }), ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("document"); expect(saveRemoteMedia).not.toHaveBeenCalled(); }, ); @@ -543,7 +559,7 @@ describe("createTelegramBot channel_post media", () => { withTelegramSpooledReplayUpdate(update, () => handler(ctx)), ); - expect(result).toBeUndefined(); + expect(result).toEqual({ kind: "completed" }); await waitForMockCalls(sendMessageSpy, 1); expect(sendMessageSpy).toHaveBeenCalledWith( 1234, @@ -552,6 +568,8 @@ describe("createTelegramBot channel_post media", () => { reply_parameters: expect.objectContaining({ message_id: 98077 }), }), ); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("document"); }); it("acks and warns a permanent fetch_failed (guard/SSRF) on spooled replay (#98076)", async () => { @@ -582,7 +600,7 @@ describe("createTelegramBot channel_post media", () => { withTelegramSpooledReplayUpdate(update, () => handler(ctx)), ); - expect(result).toBeUndefined(); + expect(result).toEqual({ kind: "completed" }); await waitForMockCalls(sendMessageSpy, 1); expect(sendMessageSpy).toHaveBeenCalledWith( 1234, @@ -591,6 +609,8 @@ describe("createTelegramBot channel_post media", () => { reply_parameters: expect.objectContaining({ message_id: 98078 }), }), ); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("document"); }); it("skips unmentioned requireMention group media before downloading (#81181)", async () => { @@ -674,7 +694,8 @@ describe("createTelegramBot channel_post media", () => { }, }, ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image", "@openclaw_bot check this"); } finally { fetchSpy.mockRestore(); } @@ -724,7 +745,8 @@ describe("createTelegramBot channel_post media", () => { }, }, ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image", caption); } finally { fetchSpy.mockRestore(); } @@ -776,7 +798,8 @@ describe("createTelegramBot channel_post media", () => { }, }, ); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledOnce(); + expectTypeOnlyMediaPayload("image"); } finally { fetchSpy.mockRestore(); } @@ -930,7 +953,7 @@ describe("createTelegramBot channel_post media", () => { expect(replySpy).toHaveBeenCalledTimes(1); expect(replyPayload()).toMatchObject({ Body: expect.stringContaining("classic restart album"), - MediaPaths: ["/tmp/classic-restart-first.jpg"], + MediaPaths: ["/tmp/classic-restart-first.jpg", ""], }); } finally { setTimeoutSpy.mockRestore(); diff --git a/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts b/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts index b039923a769..c80bc64eefe 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts @@ -216,6 +216,10 @@ describe("createTelegramBot media-group skip warning (#55216)", () => { ); const warningText = String(sendMessageSpy.mock.calls[0]?.[1]); expect(warningText).toContain("1 could not be fetched and was skipped"); + expect(replySpy.mock.calls[0]?.[0]).toMatchObject({ + MediaPaths: ["/tmp/p1.jpg", ""], + MediaTypes: ["image/png", "image"], + }); } finally { setTimeoutSpy.mockRestore(); } @@ -242,6 +246,10 @@ describe("createTelegramBot media-group skip warning (#55216)", () => { 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]).toMatchObject({ + MediaTypes: ["image", "image"], + }); + expect(replySpy.mock.calls[0]?.[0]?.MediaPaths).toBeUndefined(); } finally { setTimeoutSpy.mockRestore(); } @@ -272,6 +280,10 @@ describe("createTelegramBot media-group skip warning (#55216)", () => { 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]).toMatchObject({ + MediaPaths: ["/tmp/p1.jpg", "", ""], + MediaTypes: ["image/png", "image", "image"], + }); } finally { setTimeoutSpy.mockRestore(); } diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index 0a32309142c..f55749aaf08 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -161,7 +161,11 @@ describe("telegram inbound media", () => { expect(request.filePathHint).toBe("photos/1.jpg"); expect(params.replySpy).toHaveBeenCalledTimes(1); const payload = replyPayload(params.replySpy); - expect(payload.Body).toContain(""); + expect(payload).toMatchObject({ + BodyForAgent: "", + MediaTypes: ["image/jpeg"], + RawBody: "", + }); }, }, { @@ -175,12 +179,18 @@ describe("telegram inbound media", () => { runtimeError: ReturnType; }) => { expect(params.fetchSpy).not.toHaveBeenCalled(); - expect(params.replySpy).not.toHaveBeenCalled(); + expect(params.replySpy).toHaveBeenCalledTimes(1); + expect(replyPayload(params.replySpy)).toMatchObject({ + BodyForAgent: "", + MediaTypes: ["image"], + RawBody: "", + }); expect(params.runtimeError).not.toHaveBeenCalled(); }, }, ]) { replySpy.mockClear(); + replySpy.mockResolvedValueOnce({ text: "ack" }); runtimeError.mockClear(); const fetchSpy = scenario.setupFetch(); @@ -208,6 +218,7 @@ describe("telegram inbound media", () => { async () => { const runtimeError = vi.fn(); const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); + replySpy.mockResolvedValueOnce({ text: "ack" }); const fetchSpy = mockTelegramFileDownload({ contentType: "image/jpeg", bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), @@ -235,7 +246,7 @@ describe("telegram inbound media", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replyPayload(replySpy); - expect(payload.Body).toContain(""); + expect(payload).toMatchObject({ BodyForAgent: "", RawBody: "" }); expect(payload.MediaPaths).toContain(inboundPath); } finally { fetchSpy.mockRestore(); diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index 596eb8444e5..bc83643d31c 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -164,12 +164,9 @@ describe("telegram stickers", () => { ); it( - "skips animated and video sticker formats that cannot be downloaded", + "rejects animated and video sticker downloads before fetching bytes", async () => { const proxyFetch = vi.fn(); - const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({ - proxyFetch: proxyFetch as unknown as typeof fetch, - }); for (const scenario of [ { @@ -203,25 +200,32 @@ describe("telegram stickers", () => { }, }, ]) { - replySpy.mockClear(); - runtimeError.mockClear(); proxyFetch.mockClear(); + const getFile = vi.fn(async () => ({ file_path: scenario.filePath })); - await handler({ - message: { - message_id: scenario.messageId, - chat: { id: 1234, type: "private" }, - from: { id: 777, is_bot: false, first_name: "Ada" }, - sticker: scenario.sticker, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: scenario.filePath }), + const media = await resolveMedia({ + maxBytes: 2 * 1024 * 1024, + token: "tok", + transport: { + close: async () => {}, + fetch: proxyFetch as unknown as typeof fetch, + sourceFetch: proxyFetch as unknown as typeof fetch, + } satisfies TelegramTransport, + ctx: { + message: { + message_id: scenario.messageId, + chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + sticker: scenario.sticker, + date: 1736380800, + }, + getFile, + } as unknown as TelegramContext, }); + expect(media).toBeNull(); + expect(getFile).not.toHaveBeenCalled(); expect(proxyFetch).not.toHaveBeenCalled(); - expect(replySpy).not.toHaveBeenCalled(); - expect(runtimeError).not.toHaveBeenCalled(); } }, STICKER_TEST_TIMEOUT_MS, @@ -264,14 +268,13 @@ describe("telegram local Bot API media", () => { expect(media).toMatchObject({ path: "/tmp/telegram-media", contentType: "application/zip", - placeholder: "", + kind: "document", }); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); }); - describe("telegram text fragments", () => { afterEach(() => { vi.clearAllTimers(); diff --git a/extensions/telegram/src/bot/body-helpers.ts b/extensions/telegram/src/bot/body-helpers.ts index 4d21558933c..d182cb91f97 100644 --- a/extensions/telegram/src/bot/body-helpers.ts +++ b/extensions/telegram/src/bot/body-helpers.ts @@ -1,6 +1,9 @@ // Telegram helper module supports body helpers behavior. import type { Chat, Message, MessageOrigin, User } from "grammy/types"; -import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; +import type { + ChannelInboundMediaInput, + NormalizedLocation, +} from "openclaw/plugin-sdk/channel-inbound"; import { isRecord, normalizeLowercaseStringOrEmpty, @@ -22,8 +25,10 @@ type TelegramMediaFileRef = | NonNullable | NonNullable; +export type TelegramMediaKind = Exclude, "unknown">; + type TelegramPrimaryMedia = { - placeholder: string; + kind: TelegramMediaKind; fileRef: TelegramMediaFileRef; }; @@ -42,35 +47,29 @@ export function resolveTelegramPrimaryMedia( } const photo = msg.photo?.[msg.photo.length - 1]; if (photo) { - return { placeholder: "", fileRef: photo }; + return { kind: "image", fileRef: photo }; } if (msg.video) { - return { placeholder: "", fileRef: msg.video }; + return { kind: "video", fileRef: msg.video }; } if (msg.video_note) { - return { placeholder: "", fileRef: msg.video_note }; + return { kind: "video", fileRef: msg.video_note }; } if (msg.audio) { - return { placeholder: "", fileRef: msg.audio }; + return { kind: "audio", fileRef: msg.audio }; } if (msg.voice) { - return { placeholder: "", fileRef: msg.voice }; + return { kind: "audio", fileRef: msg.voice }; } if (msg.document) { - return { placeholder: "", fileRef: msg.document }; + return { kind: "document", fileRef: msg.document }; } if (msg.sticker) { - return { placeholder: "", fileRef: msg.sticker }; + return { kind: "sticker", fileRef: msg.sticker }; } return undefined; } -export function resolveTelegramMediaPlaceholder( - msg: TelegramMediaMessage | undefined | null, -): string | undefined { - return resolveTelegramPrimaryMedia(msg)?.placeholder; -} - export function buildSenderLabel(msg: Message, senderId?: number | string) { const name = buildSenderName(msg); const username = msg.from?.username ? `@${msg.from.username}` : undefined; diff --git a/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts new file mode 100644 index 00000000000..a564c360495 --- /dev/null +++ b/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts @@ -0,0 +1,843 @@ +// Telegram tests cover delivery.resolve media retry plugin behavior. +import { GrammyError } from "grammy"; +import type { Message } from "grammy/types"; +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"; + +const saveMediaBuffer = vi.fn(); +const readRemoteMediaBuffer = vi.fn(); +const saveRemoteMedia = vi.fn(async (...args: unknown[]) => { + const fetched = (await readRemoteMediaBuffer(...args)) as { + buffer: Buffer; + contentType?: string; + fileName?: string; + }; + return await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + args[0] && typeof args[0] === "object" + ? (args[0] as { maxBytes?: unknown }).maxBytes + : undefined, + args[0] && typeof args[0] === "object" + ? ((args[0] as { originalFilename?: unknown }).originalFilename ?? + fetched.fileName ?? + (args[0] as { filePathHint?: unknown }).filePathHint) + : undefined, + ); +}); +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("./delivery.resolve-media.runtime.js", () => { + class MediaFetchError extends Error { + code: string; + status?: number; + + constructor(code: string, message: string, options?: { cause?: unknown; status?: number }) { + super(message, options); + this.name = "MediaFetchError"; + this.code = code; + this.status = options?.status; + } + } + return { + readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args), + formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + logVerbose: () => {}, + MediaFetchError, + resolveTelegramApiBase: (apiRoot?: string) => + apiRoot?.trim() ? apiRoot.replace(/\/+$/u, "") : "https://api.telegram.org", + sleepWithAbort, + saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + saveRemoteMedia: (...args: unknown[]) => saveRemoteMedia(...args), + shouldRetryTelegramTransportFallback: vi.fn(() => false), + }; +}); + +vi.mock("../sticker-cache.js", () => ({ + cacheSticker: () => {}, + getCachedSticker: () => null, + getCacheStats: () => ({ count: 0 }), + searchStickers: () => [], + getAllCachedStickers: () => [], + describeStickerImage: async () => null, +})); + +const MAX_MEDIA_BYTES = 10_000_000; +const FIXTURE = "fixture-token"; + +function makeCtx( + mediaField: "voice" | "audio" | "photo" | "video" | "document" | "animation" | "sticker", + getFile: TelegramContext["getFile"], + opts?: { file_name?: string; mime_type?: string }, +): TelegramContext { + const msg: Record = { + message_id: 1, + date: 0, + chat: { id: 1, type: "private" }, + }; + if (mediaField === "voice") { + msg.voice = { + file_id: "v1", + duration: 5, + file_unique_id: "u1", + ...(opts?.mime_type && { mime_type: opts.mime_type }), + }; + } + if (mediaField === "audio") { + msg.audio = { + file_id: "a1", + duration: 5, + file_unique_id: "u2", + ...(opts?.file_name && { file_name: opts.file_name }), + ...(opts?.mime_type && { mime_type: opts.mime_type }), + }; + } + if (mediaField === "photo") { + msg.photo = [{ file_id: "p1", width: 100, height: 100 }]; + } + if (mediaField === "video") { + msg.video = { + file_id: "vid1", + duration: 10, + file_unique_id: "u3", + ...(opts?.file_name && { file_name: opts.file_name }), + }; + } + if (mediaField === "document") { + msg.document = { + file_id: "d1", + file_unique_id: "u4", + ...(opts?.file_name && { file_name: opts.file_name }), + ...(opts?.mime_type && { mime_type: opts.mime_type }), + }; + } + if (mediaField === "animation") { + msg.animation = { + file_id: "an1", + duration: 3, + file_unique_id: "u5", + width: 200, + height: 200, + ...(opts?.file_name && { file_name: opts.file_name }), + }; + } + if (mediaField === "sticker") { + msg.sticker = { + file_id: "stk1", + file_unique_id: "ustk1", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + }; + } + return { + message: msg as unknown as Message, + me: { + id: 1, + is_bot: true, + first_name: "bot", + username: "bot", + } as unknown as TelegramContext["me"], + getFile, + }; +} + +function setupTransientGetFileRetry() { + const getFile = vi + .fn() + .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) + .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); + + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + contentType: "audio/ogg", + fileName: "file_0.oga", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.oga", + contentType: "audio/ogg", + }); + + return getFile; +} + +function mockPdfFetchAndSave(fileName: string | undefined) { + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + contentType: "application/pdf", + fileName, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_42---uuid.pdf", + contentType: "application/pdf", + }); +} + +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[0]> = {}, +) { + return resolveMedia({ + ctx, + maxBytes: MAX_MEDIA_BYTES, + token: "fixture-token", + ...overrides, + }); +} + +function requireResolvedMedia( + result: Awaited>, + label: string, +) { + if (!result) { + throw new Error(`expected ${label} media result`); + } + return result; +} + +function requireRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`expected ${label} to be a record`); + } + return value as Record; +} + +function expectRecordFields(record: Record, fields: Record) { + for (const [key, value] of Object.entries(fields)) { + expect(record[key]).toEqual(value); + } +} + +function requireReadRemoteMediaBufferParams(callIndex = 0): Record { + const call = (readRemoteMediaBuffer.mock.calls as unknown[][])[callIndex]; + if (!call) { + throw new Error(`expected readRemoteMediaBuffer call ${callIndex}`); + } + return requireRecord(call[0], `readRemoteMediaBuffer call ${callIndex} params`); +} + +function expectReadRemoteMediaBufferFields(fields: Record, callIndex = 0) { + expectRecordFields(requireReadRemoteMediaBufferParams(callIndex), fields); +} + +function expectFetchSsrfPolicyFields(fields: Record, callIndex = 0) { + const params = requireReadRemoteMediaBufferParams(callIndex); + expectRecordFields(requireRecord(params.ssrfPolicy, "readRemoteMediaBuffer ssrfPolicy"), fields); +} + +function expectResolvedMediaFields( + result: Awaited>, + label: string, + fields: Record, +) { + expectRecordFields(requireResolvedMedia(result, label), fields); +} + +async function expectMediaFetchError( + promise: Promise, + fields: { code: string; messageIncludes: string; name?: string; status?: number }, +) { + try { + await promise; + } catch (error) { + const record = requireRecord(error, "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"); +} + +async function expectTransientGetFileRetrySuccess() { + const getFile = setupTransientGetFileRetry(); + const promise = resolveMediaWithDefaults(makeCtx("voice", getFile)); + await flushRetryTimers(); + const result = await promise; + expect(getFile).toHaveBeenCalledTimes(2); + expectReadRemoteMediaBufferFields({ + url: `https://api.telegram.org/file/bot${FIXTURE}/voice/file_0.oga`, + }); + expectFetchSsrfPolicyFields({ + allowRfc2544BenchmarkRange: true, + hostnameAllowlist: ["api.telegram.org"], + }); + return result; +} + +async function flushRetryTimers() { + await vi.runAllTimersAsync(); +} + +describe("resolveMedia getFile retry", () => { + beforeEach(() => { + vi.useFakeTimers(); + readRemoteMediaBuffer.mockReset(); + saveMediaBuffer.mockReset(); + saveRemoteMedia.mockClear(); + rootRead.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries getFile on transient failure and succeeds on second attempt", async () => { + const result = await expectTransientGetFileRetrySuccess(); + expectResolvedMediaFields(result, "retried voice", { + path: "/tmp/file_0.oga", + kind: "audio", + }); + }); + + it.each(["voice", "photo", "video"] as const)( + "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(); + await failure; + + expect(getFile).toHaveBeenCalledTimes(3); + }, + ); + + it("does not catch errors from readRemoteMediaBuffer (only getFile is retried)", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" }); + readRemoteMediaBuffer.mockRejectedValueOnce(new Error("download failed")); + + await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow( + "download failed", + ); + + expect(getFile).toHaveBeenCalledTimes(1); + }); + + 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); + + 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); + }); + + it("preserves Telegram status for 'file is too big' GrammyError instances", async () => { + const fileTooBigError = createFileTooBigGrammyError(); + const getFile = vi.fn().mockRejectedValue(fileTooBigError); + + 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); + }); + + it.each(["audio", "voice"] as const)( + "throws a typed failure for %s when file is too big", + async (mediaField) => { + const getFile = vi.fn().mockRejectedValue(createFileTooBigError()); + + await expectMediaFetchError(resolveMediaWithDefaults(makeCtx(mediaField, getFile)), { + code: "max_bytes", + messageIncludes: "larger than 20 MB", + name: "TelegramBotApiFileTooLargeError", + status: 400, + }); + + expect(getFile).toHaveBeenCalledTimes(1); + }, + ); + + it("throws when getFile returns no file_path", async () => { + const getFile = vi.fn().mockResolvedValue({}); + await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow( + "Telegram getFile returned no file_path", + ); + expect(getFile).toHaveBeenCalledTimes(1); + }); + + it("still retries transient errors even after encountering file too big in different call", async () => { + const result = await expectTransientGetFileRetrySuccess(); + // Should retry transient errors. + expect(result?.path).toBe("/tmp/file_0.oga"); + }); + + it("retries getFile for stickers on transient failure", async () => { + const getFile = vi + .fn() + .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) + .mockResolvedValueOnce({ file_path: "stickers/file_0.webp" }); + + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + contentType: "image/webp", + fileName: "file_0.webp", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.webp", + contentType: "image/webp", + }); + + const ctx = makeCtx("sticker", getFile); + const promise = resolveMediaWithDefaults(ctx); + await flushRetryTimers(); + const result = await promise; + + expect(getFile).toHaveBeenCalledTimes(2); + expectResolvedMediaFields(result, "retried sticker", { + path: "/tmp/file_0.webp", + kind: "sticker", + }); + }); + + 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(); + await failure; + + expect(getFile).toHaveBeenCalledTimes(3); + }); + + it("uses caller-provided fetch impl for file downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + const dispatcherAttempts = [ + { + dispatcherPolicy: { + mode: "explicit-proxy" as const, + proxyUrl: "http://localhost:6152", + allowPrivateProxy: true, + }, + }, + ]; + const callerTransport = { + fetch: callerFetch, + sourceFetch: callerFetch, + dispatcherAttempts, + close: async () => {}, + }; + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + contentType: "application/pdf", + fileName: "file_42.pdf", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_42---uuid.pdf", + contentType: "application/pdf", + }); + + const result = await resolveMediaWithDefaults(makeCtx("document", getFile), { + transport: callerTransport, + }); + + expect(result?.path).toBe("/tmp/file_42---uuid.pdf"); + const params = requireReadRemoteMediaBufferParams(); + expectRecordFields(params, { + fetchImpl: callerFetch, + dispatcherAttempts, + trustExplicitProxyDns: true, + responseHeaderTimeoutMs: 120_000, + readIdleTimeoutMs: 30_000, + }); + expect(params.timeoutMs).toBeUndefined(); + expect(params.retry).toBeUndefined(); + expect(typeof params.shouldRetryFetchError).toBe("function"); + expectFetchSsrfPolicyFields({ + allowRfc2544BenchmarkRange: true, + hostnameAllowlist: ["api.telegram.org"], + }); + }); + + it("uses caller-provided fetch impl for sticker downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch, close: async () => {} }; + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + contentType: "image/webp", + fileName: "file_0.webp", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.webp", + contentType: "image/webp", + }); + + const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), { + transport: callerTransport, + }); + + expect(result?.path).toBe("/tmp/file_0.webp"); + expectReadRemoteMediaBufferFields({ fetchImpl: callerFetch }); + }); + + it.each([ + { mediaField: "document" as const, filePath: "documents/file_42.pdf" }, + { mediaField: "sticker" as const, filePath: "stickers/file_0.webp" }, + ])("keeps the session abort signal attached to $mediaField downloads", async (scenario) => { + const shutdown = new AbortController(); + const getFile = vi.fn().mockResolvedValue({ file_path: scenario.filePath }); + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("media"), + contentType: scenario.mediaField === "sticker" ? "image/webp" : "application/pdf", + fileName: scenario.filePath.split("/").at(-1), + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: `/tmp/${scenario.filePath.split("/").at(-1)}`, + contentType: scenario.mediaField === "sticker" ? "image/webp" : "application/pdf", + }); + + await resolveMediaWithDefaults(makeCtx(scenario.mediaField, getFile), { + abortSignal: shutdown.signal, + }); + + expect(requireReadRemoteMediaBufferParams()).toMatchObject({ + requestInit: { signal: shutdown.signal }, + responseHeaderTimeoutMs: 120_000, + readIdleTimeoutMs: 30_000, + }); + }); + + it("omits nested download retries so callers own failure handling", async () => { + const timeout = Object.assign(new Error("request timed out"), { name: "TimeoutError" }); + const fetchError = Object.assign(new Error("failed to fetch media", { cause: timeout }), { + name: "MediaFetchError", + code: "fetch_failed", + }); + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/first.pdf" }); + readRemoteMediaBuffer.mockRejectedValueOnce(fetchError); + + await expect(resolveMediaWithDefaults(makeCtx("document", getFile))).rejects.toBe(fetchError); + + expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1); + expect(saveRemoteMedia).toHaveBeenCalledTimes(1); + expect(requireReadRemoteMediaBufferParams().retry).toBeUndefined(); + }); + + it("allows an explicit Telegram apiRoot host without broadening the default SSRF allowlist", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + readRemoteMediaBuffer.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + contentType: "application/pdf", + fileName: "file_42.pdf", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_42---uuid.pdf", + contentType: "application/pdf", + }); + + await resolveMediaWithDefaults(makeCtx("document", getFile), { + apiRoot: "https://telegram.internal:8443/custom/", + dangerouslyAllowPrivateNetwork: true, + }); + + expectReadRemoteMediaBufferFields({ + url: `https://telegram.internal:8443/custom/file/bot${FIXTURE}/documents/file_42.pdf`, + }); + expectFetchSsrfPolicyFields({ + hostnameAllowlist: ["api.telegram.org", "telegram.internal"], + allowedHostnames: ["telegram.internal"], + allowPrivateNetwork: true, + allowRfc2544BenchmarkRange: true, + }); + }); + + it("copies trusted local absolute file paths into inbound media storage for media downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + rootRead.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + realPath: "/var/lib/telegram-bot-api/file.pdf", + stat: { size: 8 }, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/inbound/file.pdf", + contentType: "application/pdf", + }); + + const result = await resolveMediaWithDefaults( + makeCtx("document", getFile, { mime_type: "application/pdf" }), + { trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] }, + ); + + expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); + expect(rootRead).toHaveBeenCalledWith({ + rootDir: "/var/lib/telegram-bot-api", + relativePath: "file.pdf", + maxBytes: MAX_MEDIA_BYTES, + }); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("pdf-data"), + "application/pdf", + "inbound", + MAX_MEDIA_BYTES, + "file.pdf", + ); + expectResolvedMediaFields(result, "trusted local document", { + path: "/tmp/inbound/file.pdf", + contentType: "application/pdf", + kind: "document", + }); + }); + + it("copies trusted local file paths whose names start with dots", async () => { + const getFile = vi + .fn() + .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/..photo.jpg" }); + rootRead.mockResolvedValueOnce({ + buffer: Buffer.from("image-data"), + realPath: "/var/lib/telegram-bot-api/..photo.jpg", + stat: { size: 10 }, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/inbound/photo.jpg", + contentType: "image/jpeg", + }); + + const result = await resolveMediaWithDefaults( + makeCtx("document", getFile, { file_name: "..photo.jpg", mime_type: "image/jpeg" }), + { trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] }, + ); + + expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); + expect(rootRead).toHaveBeenCalledWith({ + rootDir: "/var/lib/telegram-bot-api", + relativePath: "..photo.jpg", + maxBytes: MAX_MEDIA_BYTES, + }); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("image-data"), + "image/jpeg", + "inbound", + MAX_MEDIA_BYTES, + "..photo.jpg", + ); + expectResolvedMediaFields(result, "trusted local dot-prefixed document", { + path: "/tmp/inbound/photo.jpg", + contentType: "image/jpeg", + kind: "document", + }); + }); + + it("copies trusted local absolute file paths into inbound media storage for sticker downloads", async () => { + const getFile = vi + .fn() + .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" }); + rootRead.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + realPath: "/var/lib/telegram-bot-api/sticker.webp", + stat: { size: 12 }, + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/inbound/sticker.webp", + contentType: "image/webp", + }); + + const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }); + + expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); + expect(rootRead).toHaveBeenCalledWith({ + rootDir: "/var/lib/telegram-bot-api", + relativePath: "sticker.webp", + maxBytes: MAX_MEDIA_BYTES, + }); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("sticker-data"), + undefined, + "inbound", + MAX_MEDIA_BYTES, + "sticker.webp", + ); + expectResolvedMediaFields(result, "trusted local sticker", { + path: "/tmp/inbound/sticker.webp", + kind: "sticker", + }); + }); + + it("maps trusted local absolute path read failures to MediaFetchError", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + rootRead.mockRejectedValueOnce(new Error("file not found")); + + await expectMediaFetchError( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }), + { + code: "fetch_failed", + messageIncludes: "/var/lib/telegram-bot-api/file.pdf", + }, + ); + }); + + it("maps oversized trusted local absolute path reads to MediaFetchError", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + rootRead.mockRejectedValueOnce(new Error("file exceeds limit")); + + await expectMediaFetchError( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { + trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], + }), + { + code: "fetch_failed", + messageIncludes: "file exceeds limit", + }, + ); + }); + + it("rejects absolute Bot API file paths outside trustedLocalFileRoots", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); + + await expectMediaFetchError( + resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" })), + { + code: "fetch_failed", + messageIncludes: "outside trustedLocalFileRoots", + }, + ); + + expect(rootRead).not.toHaveBeenCalled(); + expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 3a018702177..66477ed5e43 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -1,5 +1,4 @@ // Telegram tests cover delivery.resolve media retry plugin behavior. -import { GrammyError } from "grammy"; import type { Message } from "grammy/types"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -158,25 +157,6 @@ function makeCtx( }; } -function setupTransientGetFileRetry() { - const getFile = vi - .fn() - .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) - .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); - - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - contentType: "audio/ogg", - fileName: "file_0.oga", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_0.oga", - contentType: "audio/ogg", - }); - - return getFile; -} - function mockPdfFetchAndSave(fileName: string | undefined) { readRemoteMediaBuffer.mockResolvedValueOnce({ buffer: Buffer.from("pdf-data"), @@ -189,38 +169,6 @@ function mockPdfFetchAndSave(fileName: string | undefined) { }); } -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 createFileAccessError(code: string, message: string): Error & { code: string } { return Object.assign(new Error(message), { code }); } @@ -316,548 +264,6 @@ function expectSaveMediaBufferCall(callIndex: number, fields: Record { - beforeEach(() => { - vi.useFakeTimers(); - readRemoteMediaBuffer.mockReset(); - saveMediaBuffer.mockReset(); - saveRemoteMedia.mockClear(); - rootRead.mockReset(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("retries getFile on transient failure and succeeds on second attempt", async () => { - const result = await expectTransientGetFileRetrySuccess(); - expectResolvedMediaFields(result, "retried voice", { - path: "/tmp/file_0.oga", - placeholder: "", - }); - }); - - it.each(["voice", "photo", "video"] as const)( - "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(); - await failure; - - expect(getFile).toHaveBeenCalledTimes(3); - }, - ); - - it("does not catch errors from readRemoteMediaBuffer (only getFile is retried)", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" }); - readRemoteMediaBuffer.mockRejectedValueOnce(new Error("download failed")); - - await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow( - "download failed", - ); - - expect(getFile).toHaveBeenCalledTimes(1); - }); - - 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); - - 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); - }); - - it("preserves Telegram status for 'file is too big' GrammyError instances", async () => { - const fileTooBigError = createFileTooBigGrammyError(); - const getFile = vi.fn().mockRejectedValue(fileTooBigError); - - 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); - }); - - it.each(["audio", "voice"] as const)( - "throws a typed failure for %s when file is too big", - async (mediaField) => { - const getFile = vi.fn().mockRejectedValue(createFileTooBigError()); - - await expectMediaFetchError(resolveMediaWithDefaults(makeCtx(mediaField, getFile)), { - code: "max_bytes", - messageIncludes: "larger than 20 MB", - name: "TelegramBotApiFileTooLargeError", - status: 400, - }); - - expect(getFile).toHaveBeenCalledTimes(1); - }, - ); - - it("throws when getFile returns no file_path", async () => { - const getFile = vi.fn().mockResolvedValue({}); - await expect(resolveMediaWithDefaults(makeCtx("voice", getFile))).rejects.toThrow( - "Telegram getFile returned no file_path", - ); - expect(getFile).toHaveBeenCalledTimes(1); - }); - - it("still retries transient errors even after encountering file too big in different call", async () => { - const result = await expectTransientGetFileRetrySuccess(); - // Should retry transient errors. - expect(result?.path).toBe("/tmp/file_0.oga"); - }); - - it("retries getFile for stickers on transient failure", async () => { - const getFile = vi - .fn() - .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) - .mockResolvedValueOnce({ file_path: "stickers/file_0.webp" }); - - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("sticker-data"), - contentType: "image/webp", - fileName: "file_0.webp", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_0.webp", - contentType: "image/webp", - }); - - const ctx = makeCtx("sticker", getFile); - const promise = resolveMediaWithDefaults(ctx); - await flushRetryTimers(); - const result = await promise; - - expect(getFile).toHaveBeenCalledTimes(2); - expectResolvedMediaFields(result, "retried sticker", { - path: "/tmp/file_0.webp", - placeholder: "", - }); - }); - - 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(); - await failure; - - expect(getFile).toHaveBeenCalledTimes(3); - }); - - it("uses caller-provided fetch impl for file downloads", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); - const callerFetch = vi.fn() as unknown as typeof fetch; - const dispatcherAttempts = [ - { - dispatcherPolicy: { - mode: "explicit-proxy" as const, - proxyUrl: "http://localhost:6152", - allowPrivateProxy: true, - }, - }, - ]; - const callerTransport = { - fetch: callerFetch, - sourceFetch: callerFetch, - dispatcherAttempts, - close: async () => {}, - }; - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("pdf-data"), - contentType: "application/pdf", - fileName: "file_42.pdf", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_42---uuid.pdf", - contentType: "application/pdf", - }); - - const result = await resolveMediaWithDefaults(makeCtx("document", getFile), { - transport: callerTransport, - }); - - expect(result?.path).toBe("/tmp/file_42---uuid.pdf"); - const params = requireReadRemoteMediaBufferParams(); - expectRecordFields(params, { - fetchImpl: callerFetch, - dispatcherAttempts, - trustExplicitProxyDns: true, - responseHeaderTimeoutMs: 120_000, - readIdleTimeoutMs: 30_000, - }); - expect(params.timeoutMs).toBeUndefined(); - expect(params.retry).toBeUndefined(); - expect(typeof params.shouldRetryFetchError).toBe("function"); - expectFetchSsrfPolicyFields({ - allowRfc2544BenchmarkRange: true, - hostnameAllowlist: ["api.telegram.org"], - }); - }); - - it("uses caller-provided fetch impl for sticker downloads", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" }); - const callerFetch = vi.fn() as unknown as typeof fetch; - const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch, close: async () => {} }; - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("sticker-data"), - contentType: "image/webp", - fileName: "file_0.webp", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_0.webp", - contentType: "image/webp", - }); - - const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), { - transport: callerTransport, - }); - - expect(result?.path).toBe("/tmp/file_0.webp"); - expectReadRemoteMediaBufferFields({ fetchImpl: callerFetch }); - }); - - it.each([ - { mediaField: "document" as const, filePath: "documents/file_42.pdf" }, - { mediaField: "sticker" as const, filePath: "stickers/file_0.webp" }, - ])("keeps the session abort signal attached to $mediaField downloads", async (scenario) => { - const shutdown = new AbortController(); - const getFile = vi.fn().mockResolvedValue({ file_path: scenario.filePath }); - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("media"), - contentType: scenario.mediaField === "sticker" ? "image/webp" : "application/pdf", - fileName: scenario.filePath.split("/").at(-1), - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: `/tmp/${scenario.filePath.split("/").at(-1)}`, - contentType: scenario.mediaField === "sticker" ? "image/webp" : "application/pdf", - }); - - await resolveMediaWithDefaults(makeCtx(scenario.mediaField, getFile), { - abortSignal: shutdown.signal, - }); - - expect(requireReadRemoteMediaBufferParams()).toMatchObject({ - requestInit: { signal: shutdown.signal }, - responseHeaderTimeoutMs: 120_000, - readIdleTimeoutMs: 30_000, - }); - }); - - it("omits nested download retries so callers own failure handling", async () => { - const timeout = Object.assign(new Error("request timed out"), { name: "TimeoutError" }); - const fetchError = Object.assign(new Error("failed to fetch media", { cause: timeout }), { - name: "MediaFetchError", - code: "fetch_failed", - }); - const getFile = vi.fn().mockResolvedValue({ file_path: "documents/first.pdf" }); - readRemoteMediaBuffer.mockRejectedValueOnce(fetchError); - - await expect(resolveMediaWithDefaults(makeCtx("document", getFile))).rejects.toBe(fetchError); - - expect(readRemoteMediaBuffer).toHaveBeenCalledTimes(1); - expect(saveRemoteMedia).toHaveBeenCalledTimes(1); - expect(requireReadRemoteMediaBufferParams().retry).toBeUndefined(); - }); - - it("allows an explicit Telegram apiRoot host without broadening the default SSRF allowlist", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); - readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("pdf-data"), - contentType: "application/pdf", - fileName: "file_42.pdf", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_42---uuid.pdf", - contentType: "application/pdf", - }); - - await resolveMediaWithDefaults(makeCtx("document", getFile), { - apiRoot: "https://telegram.internal:8443/custom/", - dangerouslyAllowPrivateNetwork: true, - }); - - expectReadRemoteMediaBufferFields({ - url: `https://telegram.internal:8443/custom/file/bot${BOT_TOKEN}/documents/file_42.pdf`, - }); - expectFetchSsrfPolicyFields({ - hostnameAllowlist: ["api.telegram.org", "telegram.internal"], - allowedHostnames: ["telegram.internal"], - allowPrivateNetwork: true, - allowRfc2544BenchmarkRange: true, - }); - }); - - it("copies trusted local absolute file paths into inbound media storage for media downloads", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); - rootRead.mockResolvedValueOnce({ - buffer: Buffer.from("pdf-data"), - realPath: "/var/lib/telegram-bot-api/file.pdf", - stat: { size: 8 }, - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/inbound/file.pdf", - contentType: "application/pdf", - }); - - const result = await resolveMediaWithDefaults( - makeCtx("document", getFile, { mime_type: "application/pdf" }), - { trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] }, - ); - - expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); - expect(rootRead).toHaveBeenCalledWith({ - rootDir: "/var/lib/telegram-bot-api", - relativePath: "file.pdf", - maxBytes: MAX_MEDIA_BYTES, - }); - expect(saveMediaBuffer).toHaveBeenCalledWith( - Buffer.from("pdf-data"), - "application/pdf", - "inbound", - MAX_MEDIA_BYTES, - "file.pdf", - ); - expectResolvedMediaFields(result, "trusted local document", { - path: "/tmp/inbound/file.pdf", - contentType: "application/pdf", - placeholder: "", - }); - }); - - it("copies trusted local file paths whose names start with dots", async () => { - const getFile = vi - .fn() - .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/..photo.jpg" }); - rootRead.mockResolvedValueOnce({ - buffer: Buffer.from("image-data"), - realPath: "/var/lib/telegram-bot-api/..photo.jpg", - stat: { size: 10 }, - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/inbound/photo.jpg", - contentType: "image/jpeg", - }); - - const result = await resolveMediaWithDefaults( - makeCtx("document", getFile, { file_name: "..photo.jpg", mime_type: "image/jpeg" }), - { trustedLocalFileRoots: ["/var/lib/telegram-bot-api"] }, - ); - - expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); - expect(rootRead).toHaveBeenCalledWith({ - rootDir: "/var/lib/telegram-bot-api", - relativePath: "..photo.jpg", - maxBytes: MAX_MEDIA_BYTES, - }); - expect(saveMediaBuffer).toHaveBeenCalledWith( - Buffer.from("image-data"), - "image/jpeg", - "inbound", - MAX_MEDIA_BYTES, - "..photo.jpg", - ); - expectResolvedMediaFields(result, "trusted local dot-prefixed document", { - path: "/tmp/inbound/photo.jpg", - contentType: "image/jpeg", - placeholder: "", - }); - }); - - it("copies trusted local absolute file paths into inbound media storage for sticker downloads", async () => { - const getFile = vi - .fn() - .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" }); - rootRead.mockResolvedValueOnce({ - buffer: Buffer.from("sticker-data"), - realPath: "/var/lib/telegram-bot-api/sticker.webp", - stat: { size: 12 }, - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/inbound/sticker.webp", - contentType: "image/webp", - }); - - const result = await resolveMediaWithDefaults(makeCtx("sticker", getFile), { - trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], - }); - - expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); - expect(rootRead).toHaveBeenCalledWith({ - rootDir: "/var/lib/telegram-bot-api", - relativePath: "sticker.webp", - maxBytes: MAX_MEDIA_BYTES, - }); - expect(saveMediaBuffer).toHaveBeenCalledWith( - Buffer.from("sticker-data"), - undefined, - "inbound", - MAX_MEDIA_BYTES, - "sticker.webp", - ); - expectResolvedMediaFields(result, "trusted local sticker", { - path: "/tmp/inbound/sticker.webp", - placeholder: "", - }); - }); - - it("maps trusted local absolute path read failures to MediaFetchError", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); - rootRead.mockRejectedValueOnce(new Error("file not found")); - - await expectMediaFetchError( - resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { - trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], - }), - { - code: "fetch_failed", - messageIncludes: "/var/lib/telegram-bot-api/file.pdf", - }, - ); - }); - - it("maps oversized trusted local absolute path reads to MediaFetchError", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); - rootRead.mockRejectedValueOnce(new Error("file exceeds limit")); - - await expectMediaFetchError( - resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" }), { - trustedLocalFileRoots: ["/var/lib/telegram-bot-api"], - }), - { - code: "fetch_failed", - messageIncludes: "file exceeds limit", - }, - ); - }); - - it("rejects absolute Bot API file paths outside trustedLocalFileRoots", async () => { - const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" }); - - await expectMediaFetchError( - resolveMediaWithDefaults(makeCtx("document", getFile, { mime_type: "application/pdf" })), - { - code: "fetch_failed", - messageIncludes: "outside trustedLocalFileRoots", - }, - ); - - expect(rootRead).not.toHaveBeenCalled(); - expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); - }); -}); - describe("resolveMedia local Bot API container paths", () => { beforeEach(() => { readRemoteMediaBuffer.mockReset(); @@ -894,7 +300,7 @@ describe("resolveMedia local Bot API container paths", () => { expectResolvedMediaFields(result, "container-mapped document", { path: "/tmp/inbound/file_12.zip", contentType: "application/zip", - placeholder: "", + kind: "document", }); }); @@ -932,7 +338,7 @@ describe("resolveMedia local Bot API container paths", () => { expectResolvedMediaFields(result, "per-token-root document", { path: "/tmp/inbound/file_7.zip", contentType: "application/zip", - placeholder: "", + kind: "document", }); }); @@ -967,7 +373,7 @@ describe("resolveMedia local Bot API container paths", () => { expectResolvedMediaFields(result, "tilde-token document", { path: "/tmp/inbound/file_9.pdf", contentType: "application/pdf", - placeholder: "", + kind: "document", }); }); @@ -1074,7 +480,7 @@ describe("resolveMedia original filename preservation", () => { expectResolvedMediaFields(result, "MPEG-2 audio document", { path: "/tmp/inbound/recording.m2a", contentType: "audio/mpeg", - placeholder: "", + kind: "audio", }); }); @@ -1226,4 +632,3 @@ describe("resolveMedia original filename preservation", () => { requireResolvedMedia(result, "custom apiRoot sticker URL"); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 574e08fe8f9..9024f59f10d 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -16,7 +16,7 @@ import { shouldRetryTelegramTransportFallback, sleepWithAbort, } from "./delivery.resolve-media.runtime.js"; -import { resolveTelegramMediaPlaceholder } from "./helpers.js"; +import { resolveTelegramPrimaryMedia, type TelegramMediaKind } from "./helpers.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; const FILE_TOO_BIG_RE = /file is too big/i; @@ -375,7 +375,7 @@ async function resolveStickerMedia(params: { | { path: string; contentType?: string; - placeholder: string; + kind: TelegramMediaKind; stickerMetadata?: StickerMetadata; } | null @@ -429,7 +429,7 @@ async function resolveStickerMedia(params: { return { path: saved.path, contentType: saved.contentType, - placeholder: "", + kind: "sticker", stickerMetadata: { emoji, setName, @@ -444,7 +444,7 @@ async function resolveStickerMedia(params: { return { path: saved.path, contentType: saved.contentType, - placeholder: "", + kind: "sticker", stickerMetadata: { emoji: sticker.emoji ?? undefined, setName: sticker.set_name ?? undefined, @@ -466,7 +466,7 @@ export async function resolveMedia(params: { }): Promise<{ path: string; contentType?: string; - placeholder: string; + kind: TelegramMediaKind; stickerMetadata?: StickerMetadata; } | null> { const { @@ -517,8 +517,12 @@ export async function resolveMedia(params: { dangerouslyAllowPrivateNetwork, abortSignal, }); - const placeholder = saved.contentType?.startsWith("audio/") - ? "" - : (resolveTelegramMediaPlaceholder(msg) ?? ""); - return { path: saved.path, contentType: saved.contentType, placeholder }; + const nativeKind = resolveTelegramPrimaryMedia(msg)?.kind ?? "document"; + const kind = + nativeKind === "sticker" + ? nativeKind + : saved.contentType?.startsWith("audio/") + ? "audio" + : nativeKind; + return { path: saved.path, contentType: saved.contentType, kind }; } diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index c0566ae1dde..d4fc50598b9 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -37,15 +37,20 @@ import { hasBotMention, isBinaryContent, normalizeForwardedContext, - resolveTelegramTextContent, - resolveTelegramMediaPlaceholder, + resolveTelegramPrimaryMedia, resolveTelegramRichMessageBody, + resolveTelegramTextContent, type TelegramForwardedContext, + type TelegramMediaKind, type TelegramTextEntity, } from "./body-helpers.js"; import type { TelegramGetChat, TelegramStreamMode } from "./types.js"; -export type { TelegramForwardedContext, TelegramTextEntity } from "./body-helpers.js"; +export type { + TelegramForwardedContext, + TelegramMediaKind, + TelegramTextEntity, +} from "./body-helpers.js"; export { buildSenderLabel, buildSenderName, @@ -54,7 +59,7 @@ export { hasBotMention, isBinaryContent, normalizeForwardedContext, - resolveTelegramMediaPlaceholder, + resolveTelegramPrimaryMedia, }; const TELEGRAM_GENERAL_TOPIC_ID = 1; @@ -589,6 +594,7 @@ export type TelegramReplyTarget = { senderId?: string; senderUsername?: string; body?: string; + mediaType?: TelegramMediaKind; kind: "reply" | "quote"; source: "reply_to_message" | "external_reply"; quoteText?: string; @@ -617,6 +623,7 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { } const replyLike = reply ?? externalReply; + const replyMedia = resolveTelegramPrimaryMedia(replyLike); const rawReplyText = replyLike && typeof replyLike.text === "string" ? replyLike.text @@ -631,19 +638,16 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { filteredReplyText = hadUnsafeTelegramText(rawReplyText, replyBody); body = replyBody; if (!body) { - body = resolveTelegramMediaPlaceholder(replyLike) ?? ""; - if (!body) { - const locationData = extractTelegramLocation(replyLike); - if (locationData) { - body = formatLocationText(locationData); - } + const locationData = extractTelegramLocation(replyLike); + if (locationData) { + body = formatLocationText(locationData); } } } if (!body && !replyLike) { return null; } - if (!body && !filteredQuoteText && !filteredReplyText) { + if (!body && !replyMedia && !filteredQuoteText && !filteredReplyText) { return null; } const sender = replyLike ? buildSenderName(replyLike) : undefined; @@ -665,6 +669,7 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { senderId: replyLike?.from?.id != null ? String(replyLike.from.id) : undefined, senderUsername: replyLike?.from?.username ?? undefined, body: body || undefined, + mediaType: replyMedia?.kind, kind, source, quoteText: kind === "quote" ? quoteText : undefined, diff --git a/extensions/telegram/src/message-cache.conversation-context.test.ts b/extensions/telegram/src/message-cache.conversation-context.test.ts new file mode 100644 index 00000000000..f261b9207f1 --- /dev/null +++ b/extensions/telegram/src/message-cache.conversation-context.test.ts @@ -0,0 +1,602 @@ +// Telegram tests cover message cache plugin behavior. +import type { Message } from "grammy/types"; +import { describe, expect, it } from "vitest"; +import { isTelegramHistoryEntryAfterAmbientWatermark } from "./group-history-window.js"; +import { + buildTelegramConversationContext, + buildTelegramReplyChain, + createTelegramMessageCache, +} from "./message-cache.js"; +import { + clearTelegramRuntimeForTest, + resetTelegramMessageCacheForTest as resetTelegramMessageCacheBucketsForTest, +} from "./runtime.test-support.js"; + +describe("telegram message cache conversation context", () => { + it("returns recent chat messages before the current message", async () => { + const cache = createTelegramMessageCache(); + for (const id of [41, 42, 43, 44]) { + await cache.record({ + accountId: "default", + chatId: 7, + threadId: 100, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops" }, + message_thread_id: 100, + message_id: id, + date: 1736380700 + id, + text: `live message ${id}`, + from: { id, is_bot: false, first_name: `User ${id}` }, + } as Message, + }); + } + await cache.record({ + accountId: "default", + chatId: 7, + threadId: 200, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops" }, + message_thread_id: 200, + message_id: 142, + date: 1736380743, + text: "different topic", + from: { id: 99, is_bot: false, first_name: "Other" }, + } as Message, + }); + + const recent = await cache.recentBefore({ + accountId: "default", + chatId: 7, + threadId: 100, + messageId: "44", + limit: 2, + }); + expect(recent.map((entry) => entry.messageId)).toEqual(["42", "43"]); + }); + + it("preserves rich-message placeholders in subsequent conversation context", async () => { + // A runtime leaked by earlier suite files binds new caches to the + // persistent keyed store; clear it so this cache stays instance-local. + clearTelegramRuntimeForTest(); + resetTelegramMessageCacheBucketsForTest(); + const cache = createTelegramMessageCache(); + const chat = { id: 7, type: "private", first_name: "Nora" } as const; + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 45, + date: 1736380745, + rich_message: { blocks: [{ type: "paragraph" }] }, + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message, + }); + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 46, + date: 1736380746, + text: "What did I just send?", + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message, + }); + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "46", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 2, + }); + + expect(context).toHaveLength(1); + expect(context[0]?.node).toMatchObject({ + messageId: "45", + body: "[unsupported Telegram rich_message received]", + }); + }); + + it("preserves rich-message text in subsequent conversation context", async () => { + // A runtime leaked by earlier suite files binds new caches to the + // persistent keyed store; clear it so this cache stays instance-local. + clearTelegramRuntimeForTest(); + resetTelegramMessageCacheBucketsForTest(); + const cache = createTelegramMessageCache(); + const chat = { id: 7, type: "private", first_name: "Nora" } as const; + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 45, + date: 1736380745, + rich_message: { + blocks: [ + { + type: "paragraph", + text: "Forwarded cache text", + }, + ], + }, + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message, + }); + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 46, + date: 1736380746, + text: "What did I just send?", + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message, + }); + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "46", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 2, + }); + + expect(context).toHaveLength(1); + expect(context[0]?.node).toMatchObject({ + messageId: "45", + body: "Forwarded cache text", + }); + }); + + it("returns nearby messages around a stale reply target", async () => { + const cache = createTelegramMessageCache(); + for (const id of [100, 101, 102, 200, 201]) { + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: id, + date: 1736380700 + id, + text: `message ${id}`, + from: { id, is_bot: false, first_name: `User ${id}` }, + } as Message, + }); + } + + const nearby = await cache.around({ + accountId: "default", + chatId: 7, + messageId: "101", + before: 1, + after: 1, + }); + expect(nearby.map((entry) => entry.messageId)).toEqual(["100", "101", "102"]); + }); + + it("selects reply targets referenced by the current local window", async () => { + const cache = createTelegramMessageCache(); + for (const id of [33867, 33868, 33869]) { + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: id, + date: 1736380000 + id, + text: `old context ${id}`, + from: { id, is_bot: false, first_name: `Old ${id}` }, + } as Message, + }); + } + for (let id = 34460; id <= 34475; id++) { + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: id, + date: 1736380000 + id, + text: `recent context ${id}`, + from: { id, is_bot: false, first_name: `Recent ${id}` }, + } as Message, + }); + } + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: 34476, + date: 1736380000 + 34476, + text: "@HamVerBot what about now", + from: { id: 34476, is_bot: false, first_name: "Ayaan" }, + reply_to_message: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: 33868, + date: 1736380000 + 33868, + text: "old context 33868", + from: { id: 33868, is_bot: false, first_name: "Old 33868" }, + } as Message["reply_to_message"], + } as Message, + }); + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "group", title: "Ops" }, + message_id: 34477, + date: 1736380000 + 34477, + text: "Show me raw input", + from: { id: 34477, is_bot: false, first_name: "Ayaan" }, + } as Message, + }); + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "34477", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 1, + }); + + expect(context.map((entry) => entry.node.messageId)).toEqual([ + "33867", + "33868", + "33869", + "34467", + "34468", + "34469", + "34470", + "34471", + "34472", + "34473", + "34474", + "34475", + "34476", + ]); + expect(context.find((entry) => entry.node.messageId === "33868")?.isReplyTarget).toBe(true); + expect(context.find((entry) => entry.node.messageId === "34477")).toBeUndefined(); + }); + + it("filters conversation context nodes when an include predicate is supplied", async () => { + const cache = createTelegramMessageCache(); + const chat = { id: 7, type: "group", title: "Ops" } as const; + for (const msg of [ + { + chat, + message_id: 600, + date: 1736380600, + text: "ambient setup chatter", + from: { id: 111, is_bot: false, first_name: "Requester" }, + }, + { + chat, + message_id: 601, + date: 1736380660, + text: "@openclaw_bot please check this", + from: { id: 222, is_bot: false, first_name: "Operator" }, + }, + { + chat, + message_id: 602, + date: 1736380720, + text: "@openclaw_bot Hello", + from: { id: 222, is_bot: false, first_name: "Operator" }, + }, + ] satisfies Message[]) { + await cache.record({ accountId: "default", chatId: 7, msg }); + } + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "602", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 1, + includeNode: (node) => node.body?.includes("@openclaw_bot") === true, + }); + + expect(context.map((entry) => entry.node.messageId)).toEqual(["601"]); + }); + + it("filters ambient transcript rows from cache-derived group context", async () => { + const cache = createTelegramMessageCache(); + const chat = { id: 7, type: "group", title: "Ops" } as const; + const timestampMs = 1_700_000_000_000; + for (const msg of [ + { + chat, + message_id: 10, + date: timestampMs / 1000, + text: "persisted ambient one", + from: { id: 101, is_bot: false, first_name: "Sam" }, + }, + { + chat, + message_id: 11, + date: (timestampMs + 1000) / 1000, + text: "persisted ambient two", + from: { id: 102, is_bot: false, first_name: "Lee" }, + }, + { + chat, + message_id: 12, + date: (timestampMs + 2000) / 1000, + text: "unpersisted gap", + from: { id: 103, is_bot: false, first_name: "Mira" }, + reply_to_message: { + chat, + message_id: 11, + date: (timestampMs + 1000) / 1000, + text: "persisted ambient two", + from: { id: 102, is_bot: false, first_name: "Lee" }, + } as Message["reply_to_message"], + }, + { + chat, + message_id: 13, + date: (timestampMs + 3000) / 1000, + text: "@openclaw_bot what happened?", + from: { id: 104, is_bot: false, first_name: "Pat" }, + }, + ] satisfies Message[]) { + await cache.record({ accountId: "default", chatId: 7, msg }); + } + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "13", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 1, + includeNode: (node, flags) => + flags?.replyTarget === true || + isTelegramHistoryEntryAfterAmbientWatermark(node, { + messageId: "11", + timestampMs: timestampMs + 1000, + }), + }); + + expect(context.map((entry) => entry.node.messageId)).toEqual(["11", "12"]); + expect(context.find((entry) => entry.node.messageId === "11")?.isReplyTarget).toBe(true); + expect(context.map((entry) => entry.node.body)).not.toContain("persisted ambient one"); + expect(context.map((entry) => entry.node.body)).toContain("unpersisted gap"); + }); + + it("does not select messages before the latest session reset command", async () => { + const cache = createTelegramMessageCache(); + const beforeSession = Date.parse("2026-05-10T12:40:00.000Z"); + const sessionStartedAt = Date.parse("2026-05-10T17:30:43.980Z"); + const afterSession = Date.parse("2026-05-11T23:36:00.000Z"); + const staleInstruction = "okay so we just flip in openclaw? if yes do it up"; + const record = (params: { + id: number; + text: string; + timestampMs: number; + replyTo?: { id: number; text: string; timestampMs: number }; + }) => + cache.record({ + accountId: "default", + chatId: 7, + threadId: 22534, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, + message_thread_id: 22534, + message_id: params.id, + date: Math.floor(params.timestampMs / 1000), + text: params.text, + from: { id: params.id, is_bot: false, first_name: "Requester" }, + ...(params.replyTo + ? { + reply_to_message: { + chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, + message_thread_id: 22534, + message_id: params.replyTo.id, + date: Math.floor(params.replyTo.timestampMs / 1000), + text: params.replyTo.text, + from: { id: params.replyTo.id, is_bot: false, first_name: "Requester" }, + } as Message["reply_to_message"], + } + : {}), + } as Message, + }); + + await record({ id: 84669, text: "earlier topic setup", timestampMs: beforeSession - 1000 }); + await record({ id: 84670, text: staleInstruction, timestampMs: beforeSession }); + await record({ id: 84671, text: "old reply context", timestampMs: beforeSession + 1000 }); + await record({ id: 85000, text: "/new", timestampMs: sessionStartedAt }); + await record({ + id: 87183, + text: "post-reset context", + timestampMs: afterSession - 60_000, + replyTo: { id: 84670, text: staleInstruction, timestampMs: beforeSession }, + }); + await record({ + id: 87184, + text: "how does this determine stability?", + timestampMs: afterSession, + }); + + const replyChainNodes = await buildTelegramReplyChain({ + cache, + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, + message_thread_id: 22534, + message_id: 87185, + date: Math.floor(afterSession / 1000) + 30, + text: "follow up", + from: { id: 87185, is_bot: false, first_name: "Requester" }, + reply_to_message: { + chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, + message_thread_id: 22534, + message_id: 84670, + date: Math.floor(beforeSession / 1000), + text: staleInstruction, + from: { id: 84670, is_bot: false, first_name: "Requester" }, + } as Message["reply_to_message"], + } as Message, + }); + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "87185", + threadId: 22534, + replyChainNodes, + recentLimit: 10, + replyTargetWindowSize: 1, + }); + + expect(context.map((entry) => entry.node.messageId)).toEqual(["87183", "87184"]); + expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction); + }); + + it("uses the current reset command as the session boundary", async () => { + const cache = createTelegramMessageCache(); + const chat = { id: 7, type: "group", title: "Ops" } as const; + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 100, + date: 1736380800, + text: "stale context", + from: { id: 100, is_bot: false, first_name: "Requester" }, + } as Message, + }); + await cache.record({ + accountId: "default", + chatId: 7, + msg: { + chat, + message_id: 101, + date: 1736380860, + text: "/new", + from: { id: 101, is_bot: false, first_name: "Requester" }, + } as Message, + }); + + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: 7, + messageId: "101", + replyChainNodes: [], + recentLimit: 10, + replyTargetWindowSize: 1, + }); + + expect(context).toEqual([]); + }); + + it("does not select messages before the persisted session start when the reset command is absent", async () => { + const cache = createTelegramMessageCache(); + const beforeSession = Date.parse("2026-05-10T12:40:00.000Z"); + const sessionStartedAt = Date.parse("2026-05-10T17:30:43.127Z"); + const afterSession = Date.parse("2026-05-11T23:36:00.000Z"); + const staleInstruction = "okay so we just flip in openclaw? if yes do it up"; + const record = (params: { + id: number; + text: string; + timestampMs: number; + replyTo?: { id: number; text: string; timestampMs: number }; + }) => + cache.record({ + accountId: "default", + chatId: -1001234567890, + threadId: 22534, + msg: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Ops", + is_forum: true, + }, + message_thread_id: 22534, + message_id: params.id, + date: Math.floor(params.timestampMs / 1000), + text: params.text, + from: { id: 101, is_bot: false, first_name: "Requester" }, + ...(params.replyTo + ? { + reply_to_message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Ops", + is_forum: true, + }, + message_thread_id: 22534, + message_id: params.replyTo.id, + date: Math.floor(params.replyTo.timestampMs / 1000), + text: params.replyTo.text, + from: { id: 101, is_bot: false, first_name: "Requester" }, + } as Message["reply_to_message"], + } + : {}), + } as Message, + }); + + await record({ + id: 84649, + text: "tools.toolSearch: true", + timestampMs: beforeSession - 5 * 60_000, + }); + await record({ id: 84670, text: staleInstruction, timestampMs: beforeSession }); + await record({ + id: 87184, + text: "how does this determine stability?", + timestampMs: afterSession, + }); + const currentNode = await record({ + id: 87227, + text: "what config change?", + timestampMs: afterSession + 2 * 60 * 60_000, + replyTo: { id: 84670, text: staleInstruction, timestampMs: beforeSession }, + }); + const current = currentNode?.sourceMessage; + if (!current) { + throw new Error("expected current Telegram message"); + } + + const replyChainNodes = await buildTelegramReplyChain({ + cache, + accountId: "default", + chatId: -1001234567890, + msg: current, + }); + const context = await buildTelegramConversationContext({ + cache, + accountId: "default", + chatId: -1001234567890, + messageId: "87227", + threadId: 22534, + replyChainNodes, + recentLimit: 10, + replyTargetWindowSize: 1, + minTimestampMs: sessionStartedAt, + }); + + expect(context.map((entry) => entry.node.messageId)).toEqual(["87184"]); + expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction); + expect(context.map((entry) => entry.node.body)).not.toContain("tools.toolSearch: true"); + }); +}); diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index 9ca0f318767..d75fc5ca8b2 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -1,7 +1,6 @@ // Telegram tests cover message cache plugin behavior. import type { Message } from "grammy/types"; import { describe, expect, it } from "vitest"; -import { isTelegramHistoryEntryAfterAmbientWatermark } from "./group-history-window.js"; import { buildTelegramConversationContext, buildTelegramReplyChain, @@ -9,10 +8,7 @@ import { resolveTelegramMessageCachePersistentScopeKey, TELEGRAM_MESSAGE_CACHE_PERSISTENT_MAX_MESSAGES, } from "./message-cache.js"; -import { - clearTelegramRuntimeForTest, - resetTelegramMessageCacheForTest as resetTelegramMessageCacheBucketsForTest, -} from "./runtime.test-support.js"; +import { resetTelegramMessageCacheForTest as resetTelegramMessageCacheBucketsForTest } from "./runtime.test-support.js"; type TelegramMessageCachePersistentStore = NonNullable< NonNullable[0]>["persistentStore"] @@ -152,7 +148,6 @@ describe("telegram message cache", () => { timestamp: 1736380700000, mediaRef: "telegram:file/photo-1", mediaType: "image", - body: "", sourceMessage: { chat: { id: 7, type: "private", first_name: "Kesava" }, message_id: 9000, @@ -284,9 +279,10 @@ describe("telegram message cache", () => { expect(updated).toMatchObject({ messageId: "104", - body: "", + mediaType: "image", mediaRef: "telegram:file/generated-photo-2", }); + expect(updated.body).toBeUndefined(); expect(updated?.body).not.toBe("old caption"); }); @@ -899,592 +895,4 @@ describe("telegram message cache", () => { expect(recent).toEqual([]); }); - - it("returns recent chat messages before the current message", async () => { - const cache = createTelegramMessageCache(); - for (const id of [41, 42, 43, 44]) { - await cache.record({ - accountId: "default", - chatId: 7, - threadId: 100, - msg: { - chat: { id: 7, type: "supergroup", title: "Ops" }, - message_thread_id: 100, - message_id: id, - date: 1736380700 + id, - text: `live message ${id}`, - from: { id, is_bot: false, first_name: `User ${id}` }, - } as Message, - }); - } - await cache.record({ - accountId: "default", - chatId: 7, - threadId: 200, - msg: { - chat: { id: 7, type: "supergroup", title: "Ops" }, - message_thread_id: 200, - message_id: 142, - date: 1736380743, - text: "different topic", - from: { id: 99, is_bot: false, first_name: "Other" }, - } as Message, - }); - - const recent = await cache.recentBefore({ - accountId: "default", - chatId: 7, - threadId: 100, - messageId: "44", - limit: 2, - }); - expect(recent.map((entry) => entry.messageId)).toEqual(["42", "43"]); - }); - - it("preserves rich-message placeholders in subsequent conversation context", async () => { - // A runtime leaked by earlier suite files binds new caches to the - // persistent keyed store; clear it so this cache stays instance-local. - clearTelegramRuntimeForTest(); - resetTelegramMessageCacheBucketsForTest(); - const cache = createTelegramMessageCache(); - const chat = { id: 7, type: "private", first_name: "Nora" } as const; - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 45, - date: 1736380745, - rich_message: { blocks: [{ type: "paragraph" }] }, - from: { id: 1, is_bot: false, first_name: "Nora" }, - } as Message, - }); - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 46, - date: 1736380746, - text: "What did I just send?", - from: { id: 1, is_bot: false, first_name: "Nora" }, - } as Message, - }); - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "46", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 2, - }); - - expect(context).toHaveLength(1); - expect(context[0]?.node).toMatchObject({ - messageId: "45", - body: "[unsupported Telegram rich_message received]", - }); - }); - - it("preserves rich-message text in subsequent conversation context", async () => { - // A runtime leaked by earlier suite files binds new caches to the - // persistent keyed store; clear it so this cache stays instance-local. - clearTelegramRuntimeForTest(); - resetTelegramMessageCacheBucketsForTest(); - const cache = createTelegramMessageCache(); - const chat = { id: 7, type: "private", first_name: "Nora" } as const; - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 45, - date: 1736380745, - rich_message: { - blocks: [ - { - type: "paragraph", - text: "Forwarded cache text", - }, - ], - }, - from: { id: 1, is_bot: false, first_name: "Nora" }, - } as Message, - }); - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 46, - date: 1736380746, - text: "What did I just send?", - from: { id: 1, is_bot: false, first_name: "Nora" }, - } as Message, - }); - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "46", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 2, - }); - - expect(context).toHaveLength(1); - expect(context[0]?.node).toMatchObject({ - messageId: "45", - body: "Forwarded cache text", - }); - }); - - it("returns nearby messages around a stale reply target", async () => { - const cache = createTelegramMessageCache(); - for (const id of [100, 101, 102, 200, 201]) { - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: id, - date: 1736380700 + id, - text: `message ${id}`, - from: { id, is_bot: false, first_name: `User ${id}` }, - } as Message, - }); - } - - const nearby = await cache.around({ - accountId: "default", - chatId: 7, - messageId: "101", - before: 1, - after: 1, - }); - expect(nearby.map((entry) => entry.messageId)).toEqual(["100", "101", "102"]); - }); - - it("selects reply targets referenced by the current local window", async () => { - const cache = createTelegramMessageCache(); - for (const id of [33867, 33868, 33869]) { - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: id, - date: 1736380000 + id, - text: `old context ${id}`, - from: { id, is_bot: false, first_name: `Old ${id}` }, - } as Message, - }); - } - for (let id = 34460; id <= 34475; id++) { - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: id, - date: 1736380000 + id, - text: `recent context ${id}`, - from: { id, is_bot: false, first_name: `Recent ${id}` }, - } as Message, - }); - } - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: 34476, - date: 1736380000 + 34476, - text: "@HamVerBot what about now", - from: { id: 34476, is_bot: false, first_name: "Ayaan" }, - reply_to_message: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: 33868, - date: 1736380000 + 33868, - text: "old context 33868", - from: { id: 33868, is_bot: false, first_name: "Old 33868" }, - } as Message["reply_to_message"], - } as Message, - }); - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "group", title: "Ops" }, - message_id: 34477, - date: 1736380000 + 34477, - text: "Show me raw input", - from: { id: 34477, is_bot: false, first_name: "Ayaan" }, - } as Message, - }); - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "34477", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 1, - }); - - expect(context.map((entry) => entry.node.messageId)).toEqual([ - "33867", - "33868", - "33869", - "34467", - "34468", - "34469", - "34470", - "34471", - "34472", - "34473", - "34474", - "34475", - "34476", - ]); - expect(context.find((entry) => entry.node.messageId === "33868")?.isReplyTarget).toBe(true); - expect(context.find((entry) => entry.node.messageId === "34477")).toBeUndefined(); - }); - - it("filters conversation context nodes when an include predicate is supplied", async () => { - const cache = createTelegramMessageCache(); - const chat = { id: 7, type: "group", title: "Ops" } as const; - for (const msg of [ - { - chat, - message_id: 600, - date: 1736380600, - text: "ambient setup chatter", - from: { id: 111, is_bot: false, first_name: "Requester" }, - }, - { - chat, - message_id: 601, - date: 1736380660, - text: "@openclaw_bot please check this", - from: { id: 222, is_bot: false, first_name: "Operator" }, - }, - { - chat, - message_id: 602, - date: 1736380720, - text: "@openclaw_bot Hello", - from: { id: 222, is_bot: false, first_name: "Operator" }, - }, - ] satisfies Message[]) { - await cache.record({ accountId: "default", chatId: 7, msg }); - } - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "602", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 1, - includeNode: (node) => node.body?.includes("@openclaw_bot") === true, - }); - - expect(context.map((entry) => entry.node.messageId)).toEqual(["601"]); - }); - - it("filters ambient transcript rows from cache-derived group context", async () => { - const cache = createTelegramMessageCache(); - const chat = { id: 7, type: "group", title: "Ops" } as const; - const timestampMs = 1_700_000_000_000; - for (const msg of [ - { - chat, - message_id: 10, - date: timestampMs / 1000, - text: "persisted ambient one", - from: { id: 101, is_bot: false, first_name: "Sam" }, - }, - { - chat, - message_id: 11, - date: (timestampMs + 1000) / 1000, - text: "persisted ambient two", - from: { id: 102, is_bot: false, first_name: "Lee" }, - }, - { - chat, - message_id: 12, - date: (timestampMs + 2000) / 1000, - text: "unpersisted gap", - from: { id: 103, is_bot: false, first_name: "Mira" }, - reply_to_message: { - chat, - message_id: 11, - date: (timestampMs + 1000) / 1000, - text: "persisted ambient two", - from: { id: 102, is_bot: false, first_name: "Lee" }, - } as Message["reply_to_message"], - }, - { - chat, - message_id: 13, - date: (timestampMs + 3000) / 1000, - text: "@openclaw_bot what happened?", - from: { id: 104, is_bot: false, first_name: "Pat" }, - }, - ] satisfies Message[]) { - await cache.record({ accountId: "default", chatId: 7, msg }); - } - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "13", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 1, - includeNode: (node, flags) => - flags?.replyTarget === true || - isTelegramHistoryEntryAfterAmbientWatermark(node, { - messageId: "11", - timestampMs: timestampMs + 1000, - }), - }); - - expect(context.map((entry) => entry.node.messageId)).toEqual(["11", "12"]); - expect(context.find((entry) => entry.node.messageId === "11")?.isReplyTarget).toBe(true); - expect(context.map((entry) => entry.node.body)).not.toContain("persisted ambient one"); - expect(context.map((entry) => entry.node.body)).toContain("unpersisted gap"); - }); - - it("does not select messages before the latest session reset command", async () => { - const cache = createTelegramMessageCache(); - const beforeSession = Date.parse("2026-05-10T12:40:00.000Z"); - const sessionStartedAt = Date.parse("2026-05-10T17:30:43.980Z"); - const afterSession = Date.parse("2026-05-11T23:36:00.000Z"); - const staleInstruction = "okay so we just flip in openclaw? if yes do it up"; - const record = (params: { - id: number; - text: string; - timestampMs: number; - replyTo?: { id: number; text: string; timestampMs: number }; - }) => - cache.record({ - accountId: "default", - chatId: 7, - threadId: 22534, - msg: { - chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, - message_thread_id: 22534, - message_id: params.id, - date: Math.floor(params.timestampMs / 1000), - text: params.text, - from: { id: params.id, is_bot: false, first_name: "Requester" }, - ...(params.replyTo - ? { - reply_to_message: { - chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, - message_thread_id: 22534, - message_id: params.replyTo.id, - date: Math.floor(params.replyTo.timestampMs / 1000), - text: params.replyTo.text, - from: { id: params.replyTo.id, is_bot: false, first_name: "Requester" }, - } as Message["reply_to_message"], - } - : {}), - } as Message, - }); - - await record({ id: 84669, text: "earlier topic setup", timestampMs: beforeSession - 1000 }); - await record({ id: 84670, text: staleInstruction, timestampMs: beforeSession }); - await record({ id: 84671, text: "old reply context", timestampMs: beforeSession + 1000 }); - await record({ id: 85000, text: "/new", timestampMs: sessionStartedAt }); - await record({ - id: 87183, - text: "post-reset context", - timestampMs: afterSession - 60_000, - replyTo: { id: 84670, text: staleInstruction, timestampMs: beforeSession }, - }); - await record({ - id: 87184, - text: "how does this determine stability?", - timestampMs: afterSession, - }); - - const replyChainNodes = await buildTelegramReplyChain({ - cache, - accountId: "default", - chatId: 7, - msg: { - chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, - message_thread_id: 22534, - message_id: 87185, - date: Math.floor(afterSession / 1000) + 30, - text: "follow up", - from: { id: 87185, is_bot: false, first_name: "Requester" }, - reply_to_message: { - chat: { id: 7, type: "supergroup", title: "Ops", is_forum: true }, - message_thread_id: 22534, - message_id: 84670, - date: Math.floor(beforeSession / 1000), - text: staleInstruction, - from: { id: 84670, is_bot: false, first_name: "Requester" }, - } as Message["reply_to_message"], - } as Message, - }); - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "87185", - threadId: 22534, - replyChainNodes, - recentLimit: 10, - replyTargetWindowSize: 1, - }); - - expect(context.map((entry) => entry.node.messageId)).toEqual(["87183", "87184"]); - expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction); - }); - - it("uses the current reset command as the session boundary", async () => { - const cache = createTelegramMessageCache(); - const chat = { id: 7, type: "group", title: "Ops" } as const; - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 100, - date: 1736380800, - text: "stale context", - from: { id: 100, is_bot: false, first_name: "Requester" }, - } as Message, - }); - await cache.record({ - accountId: "default", - chatId: 7, - msg: { - chat, - message_id: 101, - date: 1736380860, - text: "/new", - from: { id: 101, is_bot: false, first_name: "Requester" }, - } as Message, - }); - - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: 7, - messageId: "101", - replyChainNodes: [], - recentLimit: 10, - replyTargetWindowSize: 1, - }); - - expect(context).toEqual([]); - }); - - it("does not select messages before the persisted session start when the reset command is absent", async () => { - const cache = createTelegramMessageCache(); - const beforeSession = Date.parse("2026-05-10T12:40:00.000Z"); - const sessionStartedAt = Date.parse("2026-05-10T17:30:43.127Z"); - const afterSession = Date.parse("2026-05-11T23:36:00.000Z"); - const staleInstruction = "okay so we just flip in openclaw? if yes do it up"; - const record = (params: { - id: number; - text: string; - timestampMs: number; - replyTo?: { id: number; text: string; timestampMs: number }; - }) => - cache.record({ - accountId: "default", - chatId: -1001234567890, - threadId: 22534, - msg: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Ops", - is_forum: true, - }, - message_thread_id: 22534, - message_id: params.id, - date: Math.floor(params.timestampMs / 1000), - text: params.text, - from: { id: 101, is_bot: false, first_name: "Requester" }, - ...(params.replyTo - ? { - reply_to_message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Ops", - is_forum: true, - }, - message_thread_id: 22534, - message_id: params.replyTo.id, - date: Math.floor(params.replyTo.timestampMs / 1000), - text: params.replyTo.text, - from: { id: 101, is_bot: false, first_name: "Requester" }, - } as Message["reply_to_message"], - } - : {}), - } as Message, - }); - - await record({ - id: 84649, - text: "tools.toolSearch: true", - timestampMs: beforeSession - 5 * 60_000, - }); - await record({ id: 84670, text: staleInstruction, timestampMs: beforeSession }); - await record({ - id: 87184, - text: "how does this determine stability?", - timestampMs: afterSession, - }); - const currentNode = await record({ - id: 87227, - text: "what config change?", - timestampMs: afterSession + 2 * 60 * 60_000, - replyTo: { id: 84670, text: staleInstruction, timestampMs: beforeSession }, - }); - const current = currentNode?.sourceMessage; - if (!current) { - throw new Error("expected current Telegram message"); - } - - const replyChainNodes = await buildTelegramReplyChain({ - cache, - accountId: "default", - chatId: -1001234567890, - msg: current, - }); - const context = await buildTelegramConversationContext({ - cache, - accountId: "default", - chatId: -1001234567890, - messageId: "87227", - threadId: 22534, - replyChainNodes, - recentLimit: 10, - replyTargetWindowSize: 1, - minTimestampMs: sessionStartedAt, - }); - - expect(context.map((entry) => entry.node.messageId)).toEqual(["87184"]); - expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction); - expect(context.map((entry) => entry.node.body)).not.toContain("tools.toolSearch: true"); - }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 93f91b62a87..6c1d8ac10e8 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -6,7 +6,11 @@ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveTelegramPrimaryMedia, resolveTelegramRichMessageBody } from "./bot/body-helpers.js"; +import { + resolveTelegramPrimaryMedia, + resolveTelegramRichMessageBody, + type TelegramMediaKind, +} from "./bot/body-helpers.js"; import { buildSenderName, extractTelegramLocation, @@ -22,7 +26,9 @@ import { } from "./prompt-context-projection.js"; import { getOptionalTelegramRuntime } from "./runtime.js"; -export type TelegramReplyChainEntry = NonNullable[number]; +export type TelegramReplyChainEntry = NonNullable[number] & { + mediaKind?: TelegramMediaKind; +}; export type TelegramCachedMessageNode = Omit & { messageId: string; @@ -182,11 +188,7 @@ function resolveMessageBody(msg: Message, preserveWhitespace: boolean): string | if (location) { return formatLocationText(location); } - return resolveTelegramRichMessageBody(msg) ?? resolveTelegramPrimaryMedia(msg)?.placeholder; -} - -function resolveMediaType(placeholder?: string): string | undefined { - return placeholder?.match(/^]+)>$/)?.[1]; + return resolveTelegramRichMessageBody(msg); } function resolveMessageTimestamp(msg: Message): number | undefined { @@ -221,7 +223,7 @@ function normalizeMessageNode( ...(msg.from?.username ? { senderUsername: msg.from.username } : {}), ...(timestamp !== undefined ? { timestamp } : {}), ...(body ? { body } : {}), - ...(media ? { mediaType: resolveMediaType(media.placeholder) ?? media.placeholder } : {}), + ...(media ? { mediaType: media.kind } : {}), ...(fileId ? { mediaRef: `telegram:file/${fileId}` } : {}), ...(replyMessage?.message_id != null ? { replyToId: String(replyMessage.message_id) } : {}), ...(forwardedFrom?.from ? { forwardedFrom: forwardedFrom.from } : {}),