mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+14
-3
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user