fix: preserve quoted message context across channels

This commit is contained in:
xmanrui 2026-09-02 01:27:35 +08:00
parent c2be2389b0
commit 94e114b9b3
46 changed files with 4242 additions and 321 deletions

View file

@ -4,6 +4,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
import { createTelegramHttpTransport } from './telegram-http.mjs';
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
@ -138,11 +139,94 @@ function telegramFileSource(message, loadFile) {
};
}
function telegramReplyAttachment(kind, file, fallbackName) {
if (!file || typeof file !== 'object') return null;
const name = typeof file.file_name === 'string' && file.file_name
? file.file_name : typeof fallbackName === 'string' && fallbackName
? fallbackName : undefined;
return { kind, ...(name ? { name } : {}) };
}
function telegramReplyAttachments(message) {
const attachments = [];
if (Array.isArray(message?.photo) && message.photo.length > 0) {
const largest = message.photo.reduce((best, candidate) => (
photoScore(candidate) > photoScore(best) ? candidate : best
));
attachments.push(telegramReplyAttachment(
'image',
largest,
`${largest.file_unique_id ?? largest.file_id ?? 'telegram-photo'}.jpg`,
));
} else if (message?.document) {
attachments.push(telegramReplyAttachment(
imageTypeForDocument(message.document) ? 'image' : 'file',
message.document,
));
}
for (const [field, kind] of [
['audio', 'audio'],
['voice', 'audio'],
['video', 'video'],
['video_note', 'video'],
['animation', 'video'],
]) {
if (message?.[field]) attachments.push(telegramReplyAttachment(kind, message[field]));
}
if (message?.sticker) {
attachments.push(telegramReplyAttachment(
message.sticker.is_video === true ? 'video' : 'image',
message.sticker,
message.sticker.file_unique_id ?? message.sticker.file_id,
));
}
return attachments.filter(Boolean);
}
function telegramReplyReference(message, { quote, loadReplyContent } = {}) {
if ((!message || typeof message !== 'object')
&& (!quote || typeof quote !== 'object')) return undefined;
const authorId = message?.from?.id === undefined ? undefined : String(message.from.id);
const authorName = [message?.from?.first_name, message?.from?.last_name]
.filter((value) => typeof value === 'string' && value.trim())
.map((value) => value.trim()).join(' ') || (
typeof message?.from?.username === 'string' && message.from.username
? message.from.username : undefined
);
const content = typeof message?.text === 'string'
? message.text : typeof message?.caption === 'string'
? message.caption : typeof quote?.text === 'string' ? quote.text : '';
const attachments = telegramReplyAttachments(message);
const messageId = Number.isSafeInteger(message?.message_id)
? String(message.message_id) : undefined;
const createdAt = Number.isSafeInteger(message?.date) && message.date >= 0
? message.date * 1_000 : undefined;
const load = !content.trim() && attachments.length === 0
&& typeof loadReplyContent === 'function'
? ({ signal } = {}) => loadReplyContent({
...(messageId ? { messageId } : {}),
...(createdAt === undefined ? {} : { createdAt }),
}, { signal })
: null;
return {
...(messageId ? { messageId } : {}),
...(authorId ? { authorId } : {}),
...(authorName ? { authorName } : {}),
...(content.trim() ? { content } : {}),
...(attachments.length > 0 ? { attachments } : {}),
...(load ? { load } : {}),
...(!content.trim() && attachments.length === 0 && !load
? { unavailableReason: 'not-delivered' }
: {}),
};
}
export function normalizeTelegramUpdate(update, {
botId,
username,
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
loadFileStream = loadFile,
loadReplyContent,
}) {
const message = update?.message;
const chatId = message?.chat?.id;
@ -159,6 +243,21 @@ export function normalizeTelegramUpdate(update, {
? message.message_thread_id : undefined;
const image = telegramImageSource(message, loadFile);
const file = telegramFileSource(message, loadFileStream);
const conversationId = messageThreadId === undefined
? String(chatId) : `${chatId}:${messageThreadId}`;
const key = `${direct ? 'direct' : 'group'}:${conversationId}`;
const replyTo = telegramReplyReference(
message.reply_to_message ?? message.external_reply,
{
quote: message.quote,
...(typeof loadReplyContent === 'function' ? {
loadReplyContent: (reference, options) => loadReplyContent({
conversationKey: key,
...reference,
}, options),
} : {}),
},
);
return {
messageId: String(update.update_id),
senderId: String(senderId),
@ -170,12 +269,12 @@ export function normalizeTelegramUpdate(update, {
}),
senderIsBot: message.from?.is_bot === true,
kind: direct ? 'direct' : 'group',
conversationId: messageThreadId === undefined
? String(chatId) : `${chatId}:${messageThreadId}`,
conversationId,
content: withoutBotMention(message.text ?? message.caption ?? '', username),
plainText: typeof message.text === 'string',
images: image ? [image] : [],
files: file ? [file] : [],
...(replyTo ? { replyTo } : {}),
addressed,
reactionTarget: { chatId, messageId },
replyTarget: {
@ -839,6 +938,21 @@ export class TelegramRuntime {
}
}
async #loadReplyContent(reference, { signal } = {}) {
const key = typeof reference?.conversationKey === 'string'
? reference.conversationKey.trim() : '';
const quotedAt = Number(reference?.createdAt);
if (!key || !Number.isFinite(quotedAt)) {
return { unavailableReason: 'not-delivered' };
}
const sessionId = this.#state.sessionFor(key);
const session = typeof sessionId === 'string' && sessionId
? this.#harness.workspaceSession?.(sessionId)
: null;
const text = await recoverAssistantTextByTimestamp({ session, quotedAt, signal });
return text ? { content: text } : { unavailableReason: 'not-delivered' };
}
async #poll(initialCursor, signal) {
let cursor = initialCursor;
while (!signal.aborted) {
@ -863,6 +977,7 @@ export class TelegramRuntime {
username: this.#config.username,
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
});
if (message) {
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {