Skip to content

Commit 1c30bb8

Browse files
fix(telegram): preserve sticker media paths (#93130)
* fix(telegram): preserve sticker media paths * fix(telegram): address PR validation failures * fix(telegram): preserve sticker media context * test(telegram): fix sticker proof checks --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 94833b2 commit 1c30bb8

21 files changed

Lines changed: 660 additions & 141 deletions

extensions/telegram/src/bot-handlers.runtime.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Telegram plugin module implements bot handlers behavior.
22
import { randomUUID } from "node:crypto";
3+
import path from "node:path";
34
import type { Message, ReactionTypeEmoji } from "grammy/types";
45
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
56
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
@@ -160,6 +161,39 @@ import {
160161
} from "./network-errors.js";
161162
import { buildInlineKeyboard } from "./send.js";
162163

164+
function resolveTelegramPromptMediaPath(mediaPath: string): string | undefined {
165+
const toInboundMediaPath = (id: string): string | undefined => {
166+
if (
167+
!id ||
168+
id === "." ||
169+
id === ".." ||
170+
id.includes("/") ||
171+
id.includes("\\") ||
172+
id.includes("\0")
173+
) {
174+
return undefined;
175+
}
176+
return `media://inbound/${encodeURIComponent(id)}`;
177+
};
178+
const decodeInboundMediaId = (id: string): string | undefined => {
179+
try {
180+
return decodeURIComponent(id);
181+
} catch {
182+
return undefined;
183+
}
184+
};
185+
const canonicalMatch = /^media:\/\/inbound\/([^/\\]+)$/i.exec(mediaPath);
186+
if (canonicalMatch?.[1]) {
187+
const id = decodeInboundMediaId(canonicalMatch[1]);
188+
return id ? toInboundMediaPath(id) : undefined;
189+
}
190+
const normalized = mediaPath.replace(/\\/g, "/");
191+
if (!normalized.includes("/media/inbound/")) {
192+
return undefined;
193+
}
194+
return toInboundMediaPath(path.posix.basename(normalized));
195+
}
196+
163197
export const registerTelegramHandlers = ({
164198
cfg,
165199
accountId,
@@ -1109,16 +1143,21 @@ export const registerTelegramHandlers = ({
11091143
media?: TelegramMediaRef,
11101144
): TelegramReplyChainEntry => {
11111145
const { sourceMessage: _sourceMessage, ...entry } = node;
1146+
if (!media?.path) {
1147+
return entry;
1148+
}
1149+
const { mediaRef: _mediaRef, ...entryWithoutProviderMediaRef } = entry;
11121150
return {
1113-
...entry,
1114-
...(media?.path ? { mediaPath: media.path } : {}),
1151+
...entryWithoutProviderMediaRef,
1152+
mediaPath: media.path,
11151153
...(media?.contentType ? { mediaType: media.contentType } : {}),
11161154
};
11171155
};
11181156

11191157
const toPromptContextMessage = (
11201158
node: TelegramCachedMessageNode,
11211159
flags?: { replyTarget?: boolean },
1160+
media?: TelegramMediaRef,
11221161
) => ({
11231162
message_id: node.messageId,
11241163
thread_id: node.threadId,
@@ -1127,8 +1166,9 @@ export const registerTelegramHandlers = ({
11271166
sender_username: node.senderUsername,
11281167
timestamp_ms: node.timestamp,
11291168
body: node.body,
1130-
media_type: node.mediaType,
1131-
media_ref: node.mediaRef,
1169+
media_type: media?.contentType ?? node.mediaType,
1170+
media_path: media?.path,
1171+
media_ref: media?.path ? undefined : node.mediaRef,
11321172
reply_to_id: node.replyToId,
11331173
is_reply_target: flags?.replyTarget === true ? true : undefined,
11341174
});
@@ -1137,6 +1177,7 @@ export const registerTelegramHandlers = ({
11371177
msg: Message,
11381178
replyChainNodes: TelegramCachedMessageNode[],
11391179
options?: TelegramMessageContextOptions,
1180+
mediaByMessageId?: ReadonlyMap<string, TelegramMediaRef>,
11401181
): Promise<TelegramPromptContextEntry[]> => {
11411182
const messageId = typeof msg.message_id === "number" ? String(msg.message_id) : undefined;
11421183
const currentNode = await messageCache.get({
@@ -1168,7 +1209,11 @@ export const registerTelegramHandlers = ({
11681209
order: "chronological",
11691210
relation: "selected_for_current_message",
11701211
messages: conversationContext.map((entry) =>
1171-
toPromptContextMessage(entry.node, { replyTarget: entry.isReplyTarget }),
1212+
toPromptContextMessage(
1213+
entry.node,
1214+
{ replyTarget: entry.isReplyTarget },
1215+
entry.node.messageId ? mediaByMessageId?.get(entry.node.messageId) : undefined,
1216+
),
11721217
),
11731218
},
11741219
},
@@ -1240,10 +1285,39 @@ export const registerTelegramHandlers = ({
12401285
params.ctx,
12411286
replyChainNodes,
12421287
);
1288+
const promptContextMediaByMessageId = new Map<string, TelegramMediaRef>();
1289+
const currentMessageId =
1290+
typeof params.msg.message_id === "number" ? String(params.msg.message_id) : undefined;
1291+
const currentMedia = params.allMedia[0];
1292+
const currentPromptMediaPath = currentMedia?.path
1293+
? resolveTelegramPromptMediaPath(currentMedia.path)
1294+
: undefined;
1295+
if (currentMessageId && currentMedia && currentPromptMediaPath) {
1296+
promptContextMediaByMessageId.set(currentMessageId, {
1297+
...currentMedia,
1298+
path: currentPromptMediaPath,
1299+
});
1300+
}
1301+
const isGroupConversation =
1302+
params.msg.chat.type === "group" || params.msg.chat.type === "supergroup";
1303+
if (!isGroupConversation) {
1304+
for (const entry of replyChain) {
1305+
const promptMediaPath = entry.mediaPath
1306+
? resolveTelegramPromptMediaPath(entry.mediaPath)
1307+
: undefined;
1308+
if (entry.messageId && entry.mediaPath && promptMediaPath) {
1309+
promptContextMediaByMessageId.set(entry.messageId, {
1310+
path: promptMediaPath,
1311+
...(entry.mediaType ? { contentType: entry.mediaType } : {}),
1312+
});
1313+
}
1314+
}
1315+
}
12431316
const promptContext = await buildPromptContextForMessage(
12441317
params.msg,
12451318
replyChainNodes,
12461319
params.options,
1320+
promptContextMediaByMessageId,
12471321
);
12481322
const result = await processMessage(
12491323
params.ctx,

extensions/telegram/src/bot-message-context.body.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,73 @@ describe("resolveTelegramInboundBody", () => {
166166
expect(result?.bodyText).toBe("<media:document> (2 attachments)");
167167
});
168168

169+
it("preserves cached sticker descriptions when downloaded media exists", async () => {
170+
const result = await resolveTelegramBody({
171+
msg: {
172+
message_id: 6,
173+
date: 1_700_000_006,
174+
chat: { id: 42, type: "private", first_name: "Pat" },
175+
from: { id: 42, first_name: "Pat" },
176+
sticker: {
177+
file_id: "sticker-1",
178+
file_unique_id: "sticker-u1",
179+
type: "regular",
180+
width: 256,
181+
height: 256,
182+
is_animated: false,
183+
is_video: false,
184+
emoji: "ok",
185+
set_name: "test-set",
186+
},
187+
} as never,
188+
allMedia: [
189+
{
190+
path: "/tmp/sticker.webp",
191+
contentType: "image/webp",
192+
stickerMetadata: {
193+
emoji: "ok",
194+
setName: "test-set",
195+
cachedDescription: "Cached description",
196+
},
197+
},
198+
],
199+
});
200+
201+
expect(result?.bodyText).toBe('[Sticker ok from "test-set"] Cached description');
202+
expect(result?.stickerCacheHit).toBe(true);
203+
});
204+
205+
it("includes cached sticker descriptions with user captions", async () => {
206+
const result = await resolveTelegramBody({
207+
msg: {
208+
message_id: 7,
209+
date: 1_700_000_007,
210+
chat: { id: 42, type: "private", first_name: "Pat" },
211+
from: { id: 42, first_name: "Pat" },
212+
caption: "What is this?",
213+
sticker: {
214+
file_id: "sticker-2",
215+
file_unique_id: "sticker-u2",
216+
type: "regular",
217+
width: 256,
218+
height: 256,
219+
is_animated: false,
220+
is_video: false,
221+
},
222+
} as never,
223+
allMedia: [
224+
{
225+
path: "/tmp/sticker.webp",
226+
contentType: "image/webp",
227+
stickerMetadata: { cachedDescription: "Cached description" },
228+
},
229+
],
230+
});
231+
232+
expect(result?.bodyText).toBe("[Sticker] Cached description\nWhat is this?");
233+
expect(result?.stickerCacheHit).toBe(true);
234+
});
235+
169236
it("lets catch-all mention patterns activate captionless group photos", async () => {
170237
const logger = { info: vi.fn() };
171238

extensions/telegram/src/bot-message-context.body.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ export async function resolveTelegramInboundBody(params: {
246246
}
247247

248248
let bodyText = rawBody;
249+
if (stickerCacheHit && placeholder && rawBody !== placeholder) {
250+
bodyText = `${placeholder}\n${bodyText}`.trim();
251+
}
249252
if (allMedia.length === 0 && placeholder && rawBody !== placeholder) {
250253
const mediaTag = primaryMedia?.fileRef.file_id
251254
? `${placeholder} [file_id:${primaryMedia.fileRef.file_id}]`
@@ -304,7 +307,13 @@ export async function resolveTelegramInboundBody(params: {
304307
}
305308

306309
const savedMediaPlaceholder = formatSavedMediaPlaceholder(allMedia);
307-
if (!hasAudio && savedMediaPlaceholder && placeholder && bodyText === placeholder) {
310+
if (
311+
!stickerCacheHit &&
312+
!hasAudio &&
313+
savedMediaPlaceholder &&
314+
placeholder &&
315+
bodyText === placeholder
316+
) {
308317
bodyText = savedMediaPlaceholder;
309318
}
310319
if (!bodyText && allMedia.length > 0) {

extensions/telegram/src/bot-message-context.session.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Telegram plugin module implements bot message context.session behavior.
2+
import path from "node:path";
23
import {
34
type BuildChannelInboundEventContextAsyncParams,
45
type BuiltChannelInboundEventContext,
@@ -134,8 +135,42 @@ function stripReplyChainForwarded(entry: TelegramReplyChainEntry): TelegramReply
134135
return withoutForwarded;
135136
}
136137

138+
function resolveTelegramPromptMediaPath(mediaPath: string): string | undefined {
139+
const toInboundMediaPath = (id: string): string | undefined => {
140+
if (
141+
!id ||
142+
id === "." ||
143+
id === ".." ||
144+
id.includes("/") ||
145+
id.includes("\\") ||
146+
id.includes("\0")
147+
) {
148+
return undefined;
149+
}
150+
return `media://inbound/${encodeURIComponent(id)}`;
151+
};
152+
const decodeInboundMediaId = (id: string): string | undefined => {
153+
try {
154+
return decodeURIComponent(id);
155+
} catch {
156+
return undefined;
157+
}
158+
};
159+
const canonicalMatch = /^media:\/\/inbound\/([^/\\]+)$/i.exec(mediaPath);
160+
if (canonicalMatch?.[1]) {
161+
const id = decodeInboundMediaId(canonicalMatch[1]);
162+
return id ? toInboundMediaPath(id) : undefined;
163+
}
164+
const normalized = mediaPath.replace(/\\/g, "/");
165+
if (!normalized.includes("/media/inbound/")) {
166+
return undefined;
167+
}
168+
return toInboundMediaPath(path.posix.basename(normalized));
169+
}
170+
137171
function formatReplyChainEntry(entry: TelegramReplyChainEntry, index: number): string {
138172
const forwardedAt = timestampMsToIsoString(entry.forwardedDate);
173+
const mediaPath = entry.mediaPath ? resolveTelegramPromptMediaPath(entry.mediaPath) : undefined;
139174
const labels = [
140175
`${index + 1}. ${entry.sender ?? "unknown sender"}`,
141176
entry.messageId ? `id:${entry.messageId}` : undefined,
@@ -148,7 +183,7 @@ function formatReplyChainEntry(entry: TelegramReplyChainEntry, index: number): s
148183
: undefined,
149184
entry.isQuote && entry.body ? `"${entry.body}"` : entry.body,
150185
entry.mediaType ? `<media:${entry.mediaType}>` : undefined,
151-
entry.mediaPath ? `[media_path:${entry.mediaPath}]` : undefined,
186+
mediaPath ? `[media_path:${mediaPath}]` : undefined,
152187
entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined,
153188
].filter(Boolean);
154189
return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`;
@@ -178,9 +213,9 @@ export async function buildTelegramInboundContextPayload(params: {
178213
groupHistories: Map<string, HistoryEntry[]>;
179214
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
180215
topicConfig?: TelegramTopicConfig;
181-
stickerCacheHit: boolean;
182216
effectiveWasMentioned: boolean;
183217
hasControlCommand: boolean;
218+
stickerCacheHit?: boolean;
184219
audioTranscribedMediaIndex?: number;
185220
commandAuthorized: boolean;
186221
locationData?: NormalizedLocation;
@@ -227,9 +262,9 @@ export async function buildTelegramInboundContextPayload(params: {
227262
groupHistories,
228263
groupConfig,
229264
topicConfig,
230-
stickerCacheHit,
231265
effectiveWasMentioned,
232266
hasControlCommand,
267+
stickerCacheHit,
233268
audioTranscribedMediaIndex,
234269
commandAuthorized,
235270
locationData,
@@ -410,15 +445,14 @@ export async function buildTelegramInboundContextPayload(params: {
410445
limit: historyLimit,
411446
})
412447
: undefined;
413-
const currentMediaForContext = stickerCacheHit ? [] : allMedia;
414448
const replyHead = visibleReplyChain[0];
415449
const toInboundMedia = (media: TelegramMediaRef, index?: number) => ({
416450
path: media.path,
417451
url: media.path,
418452
contentType: media.contentType,
419453
transcribed: index !== undefined && audioTranscribedMediaIndex === index,
420454
});
421-
const currentMediaFacts = currentMediaForContext.map(toInboundMedia);
455+
const currentMediaFacts = allMedia.map(toInboundMedia);
422456
const replyMediaFacts =
423457
visibleReplyChain.length > 0
424458
? visibleReplyChain.flatMap((entry) =>
@@ -561,7 +595,8 @@ export async function buildTelegramInboundContextPayload(params: {
561595
ForwardedFromMessageId: visibleForwardOrigin?.fromMessageId,
562596
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
563597
Sticker: allMedia[0]?.stickerMetadata,
564-
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
598+
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? currentMediaFacts.length > 0 : undefined,
599+
SkipStickerMediaUnderstanding: stickerCacheHit ? true : undefined,
565600
...locationContext,
566601
IsForum: isForum,
567602
TopicName: isForum && topicName ? topicName : undefined,

0 commit comments

Comments
 (0)