feat(channels): batch 4 Telegram drops media placeholder bodies (#111855)

Final producer batch of the media-placeholder program: Telegram primary
bodies are caption-only with one aligned structured fact per native
media; the message-cache kind-parsing regex is deleted (native kind
stored directly); reply-chain, debounce/forward, group-history, and
ambient transcript lines render structured facts via the shared
formatter (removing the plugin-local duplicate of the caption-less
literal); audio-transcript and sticker-description replacements gate on
structured facts instead of exact placeholder strings. Also restores
unconditional failed-retryable recording when media resolution is
aborted for live updates, so shutdown cannot silently settle an
undispatched update.
This commit is contained in:
Peter Steinberger
2026-07-20 07:31:10 -07:00
committed by GitHub
parent 570f8b4d9e
commit 5197428add
33 changed files with 2064 additions and 1458 deletions
-2
View File
@@ -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
-1
View File
@@ -55,7 +55,6 @@ export {
resolveTelegramForumFlag,
resolveTelegramForumThreadId,
resolveTelegramGroupAllowFromContext,
resolveTelegramMediaPlaceholder,
resolveTelegramReplyId,
resolveTelegramStreamMode,
resolveTelegramThreadSpec,
@@ -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<string, "include" | "exclude">();
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,
@@ -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<ReturnType<typeof resolveMedia>>;
const nativeMedia = resolveTelegramPrimaryMedia(msg);
let media: Awaited<ReturnType<typeof resolveMedia>> = 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({
@@ -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,
@@ -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(" ");
@@ -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<string, unknown>): 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: <media:image>");
});
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: <media:attachment>");
});
});
@@ -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 } : {}),
});
}
@@ -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<ReturnType<typeof buildGroupVoiceContext>>) {
function expectAudioFactWithEmptyBody(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
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);
});
});
@@ -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("<media:video> [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("<media:image>");
expect(result?.bodyText).toBe("<media:document>");
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("<media:image> (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("<media:document> (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("<media:image>");
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("<media:image>");
expect(result?.bodyText).toBe("<media:image>");
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",
@@ -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 `<media:${kind}>`;
}
if (kind === "image") {
return `<media:image> (${allMedia.length} images)`;
}
if (kind === "video") {
return `<media:video> (${allMedia.length} videos)`;
}
if (kind === "audio") {
return `<media:audio> (${allMedia.length} audio attachments)`;
}
return `<media:document> (${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 === "<media:audio>" && 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)
: "<media:audio>";
} else {
bodyText = savedMediaPlaceholder ?? "<media:document>";
}
}
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,
},
@@ -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: [
{
@@ -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("<media:image>");
});
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("<media:sticker>");
expect(context?.ctxPayload.Body).not.toContain("<media:image>");
});
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("<media:image>");
expect(context?.ctxPayload.Body).toContain("<media:document>");
});
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("<media:image>");
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("<media:image>");
});
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");
});
});
@@ -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 ? `<media:${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<TelegramMediaKind>([
"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,
},
@@ -61,6 +61,7 @@ describe("buildTelegramMessageContext sticker media", () => {
{
path: stickerPath,
contentType: "image/webp",
kind: "sticker",
stickerMetadata: {
emoji: "🔥",
setName: "NewSet",
@@ -25,6 +25,7 @@ type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
me?: Record<string, unknown>;
allMedia?: TelegramMediaRef[];
replyChain?: BuildTelegramMessageContextParams["replyChain"];
promptContext?: BuildTelegramMessageContextParams["promptContext"];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
@@ -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 ?? {},
@@ -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;
@@ -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);
+24 -16
View File
@@ -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 === "<media:image>") {
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({
+1 -1
View File
@@ -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" });
+1 -1
View File
@@ -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 =
@@ -231,6 +231,17 @@ function replyPayload(): Record<string, unknown> {
return call[0] as Record<string, unknown>;
}
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<string, unknown>) => Promise<void>;
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();
@@ -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();
}
@@ -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("<media:image>");
expect(payload).toMatchObject({
BodyForAgent: "",
MediaTypes: ["image/jpeg"],
RawBody: "",
});
},
},
{
@@ -175,12 +179,18 @@ describe("telegram inbound media", () => {
runtimeError: ReturnType<typeof vi.fn>;
}) => {
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("<media:image>");
expect(payload).toMatchObject({ BodyForAgent: "", RawBody: "" });
expect(payload.MediaPaths).toContain(inboundPath);
} finally {
fetchSpy.mockRestore();
@@ -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: "<media:document>",
kind: "document",
});
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
});
describe("telegram text fragments", () => {
afterEach(() => {
vi.clearAllTimers();
+14 -15
View File
@@ -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<Message["document"]>
| NonNullable<Message["sticker"]>;
export type TelegramMediaKind = Exclude<NonNullable<ChannelInboundMediaInput["kind"]>, "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: "<media:image>", fileRef: photo };
return { kind: "image", fileRef: photo };
}
if (msg.video) {
return { placeholder: "<media:video>", fileRef: msg.video };
return { kind: "video", fileRef: msg.video };
}
if (msg.video_note) {
return { placeholder: "<media:video>", fileRef: msg.video_note };
return { kind: "video", fileRef: msg.video_note };
}
if (msg.audio) {
return { placeholder: "<media:audio>", fileRef: msg.audio };
return { kind: "audio", fileRef: msg.audio };
}
if (msg.voice) {
return { placeholder: "<media:audio>", fileRef: msg.voice };
return { kind: "audio", fileRef: msg.voice };
}
if (msg.document) {
return { placeholder: "<media:document>", fileRef: msg.document };
return { kind: "document", fileRef: msg.document };
}
if (msg.sticker) {
return { placeholder: "<media:sticker>", 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;
@@ -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<string, unknown> = {
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<Parameters<typeof resolveMedia>[0]> = {},
) {
return resolveMedia({
ctx,
maxBytes: MAX_MEDIA_BYTES,
token: "fixture-token",
...overrides,
});
}
function requireResolvedMedia(
result: Awaited<ReturnType<typeof resolveMediaWithDefaults>>,
label: string,
) {
if (!result) {
throw new Error(`expected ${label} media result`);
}
return result;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
for (const [key, value] of Object.entries(fields)) {
expect(record[key]).toEqual(value);
}
}
function requireReadRemoteMediaBufferParams(callIndex = 0): Record<string, unknown> {
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<string, unknown>, callIndex = 0) {
expectRecordFields(requireReadRemoteMediaBufferParams(callIndex), fields);
}
function expectFetchSsrfPolicyFields(fields: Record<string, unknown>, callIndex = 0) {
const params = requireReadRemoteMediaBufferParams(callIndex);
expectRecordFields(requireRecord(params.ssrfPolicy, "readRemoteMediaBuffer ssrfPolicy"), fields);
}
function expectResolvedMediaFields(
result: Awaited<ReturnType<typeof resolveMediaWithDefaults>>,
label: string,
fields: Record<string, unknown>,
) {
expectRecordFields(requireResolvedMedia(result, label), fields);
}
async function expectMediaFetchError(
promise: Promise<unknown>,
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();
});
});
@@ -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<string, unk
expect(call[4]).toBe(fields.fileName);
}
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${BOT_TOKEN}/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",
placeholder: "<media: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",
placeholder: "<media: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${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: "<media: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",
placeholder: "<media: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",
placeholder: "<media: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();
});
});
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: "<media:document>",
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: "<media:document>",
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: "<media:document>",
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: "<media:audio>",
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. */
@@ -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: "<media:sticker>",
kind: "sticker",
stickerMetadata: {
emoji,
setName,
@@ -444,7 +444,7 @@ async function resolveStickerMedia(params: {
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
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/")
? "<media:audio>"
: (resolveTelegramMediaPlaceholder(msg) ?? "<media:document>");
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 };
}
+16 -11
View File
@@ -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,
@@ -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");
});
});
+3 -595
View File
@@ -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<Parameters<typeof createTelegramMessageCache>[0]>["persistentStore"]
@@ -152,7 +148,6 @@ describe("telegram message cache", () => {
timestamp: 1736380700000,
mediaRef: "telegram:file/photo-1",
mediaType: "image",
body: "<media:image>",
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: "<media:image>",
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. */
+10 -8
View File
@@ -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<MsgContext["ReplyChain"]>[number];
export type TelegramReplyChainEntry = NonNullable<MsgContext["ReplyChain"]>[number] & {
mediaKind?: TelegramMediaKind;
};
export type TelegramCachedMessageNode = Omit<TelegramReplyChainEntry, "messageId"> & {
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(/^<media:([^>]+)>$/)?.[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 } : {}),