fix: wire telegram quote support (#2900)

Co-authored-by: aduk059 <aduk059@users.noreply.github.com>
This commit is contained in:
Gustavo Madeira Santana 2026-01-27 15:50:47 -05:00
parent ca431942d7
commit 1f73dac923
15 changed files with 225 additions and 38 deletions

View File

@ -60,6 +60,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
quoteText: Type.Optional(
Type.String({ description: "Quote text for Telegram reply_parameters" }),
),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(

View File

@ -261,6 +261,31 @@ describe("handleTelegramAction", () => {
);
});
it("passes quoteText when provided", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as MoltbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Replying now",
replyToMessageId: 144,
quoteText: "The text you want to quote",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Replying now",
expect.objectContaining({
token: "tok",
replyToMessageId: 144,
quoteText: "The text you want to quote",
}),
);
});
it("allows media-only messages without content", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },

View File

@ -165,6 +165,7 @@ export async function handleTelegramAction(
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const quoteText = readStringParam(params, "quoteText");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
@ -178,6 +179,7 @@ export async function handleTelegramAction(
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
});

View File

@ -49,6 +49,7 @@ export type MsgContext = {
ReplyToIdFull?: string;
ReplyToBody?: string;
ReplyToSender?: string;
ReplyToIsQuote?: boolean;
ForwardedFrom?: string;
ForwardedFromType?: string;
ForwardedFromId?: string;

View File

@ -1 +1 @@
b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4
bd5789522e6bde45274e15fdd45b10c9a41da378b190d6f42cef5ef2a69d72a7

View File

@ -23,6 +23,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
const quoteText = readStringParam(params, "quoteText");
return {
to,
content,
@ -32,6 +33,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
buttons,
asVoice,
silent,
quoteText: quoteText ?? undefined,
};
}

View File

@ -56,8 +56,10 @@ export const telegramOutbound: ChannelOutboundAdapter = {
const replyToMessageId = parseReplyToMessageId(replyToId);
const messageThreadId = parseThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
| { buttons?: Array<Array<{ text: string; callback_data: string }>>; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
@ -69,6 +71,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
textMode: "html" as const,
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
};

View File

@ -480,13 +480,13 @@ export const buildTelegramMessageContext = async ({
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
const replySuffix = replyTarget
? (replyTarget as any).isQuote
? `\n\n[Quoting ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n"${replyTarget.body}"\n[/Quoting]`
: `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
? replyTarget.kind === "quote"
? `\n\n[Quoting ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n"${replyTarget.body}"\n[/Quoting]`
: `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
: "";
const forwardPrefix = forwardOrigin
? `[Forwarded from ${forwardOrigin.from}${
@ -569,6 +569,7 @@ export const buildTelegramMessageContext = async ({
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
ForwardedFrom: forwardOrigin?.from,
ForwardedFromType: forwardOrigin?.fromType,
ForwardedFromId: forwardOrigin?.fromId,

View File

@ -210,6 +210,10 @@ export const dispatchTelegramMessage = async ({
draftStream?.stop();
}
const replyQuoteText =
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
? ctxPayload.ReplyToBody.trim() || undefined
: undefined;
await deliverReplies({
replies: [payload],
chatId: String(chatId),
@ -223,6 +227,7 @@ export const dispatchTelegramMessage = async ({
chunkMode,
onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
});
},
onError: (err, info) => {

View File

@ -894,6 +894,73 @@ describe("createTelegramBot", () => {
expect(payload.ReplyToSender).toBe("Ada");
});
it("uses quote text when a Telegram partial reply is received", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
reply_to_message: {
message_id: 9001,
text: "Can you summarize this?",
from: { first_name: "Ada" },
},
quote: {
text: "summarize this",
},
},
me: { username: "moltbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("[Quoting Ada id:9001]");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBe("9001");
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("Ada");
});
it("handles quote-only replies without reply metadata", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "Sure, see below",
date: 1736380800,
quote: {
text: "summarize this",
},
},
me: { username: "moltbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("[Quoting unknown sender]");
expect(payload.Body).toContain('"summarize this"');
expect(payload.ReplyToId).toBeUndefined();
expect(payload.ReplyToBody).toBe("summarize this");
expect(payload.ReplyToSender).toBe("unknown sender");
});
it("sends replies without native reply threading", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();

View File

@ -168,6 +168,37 @@ describe("deliverReplies", () => {
);
});
it("uses reply_parameters when quote text is provided", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: "123" },
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: "Hello there", replyToId: "500" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "all",
textLimit: 4000,
replyQuoteText: "quoted text",
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
reply_parameters: {
message_id: 500,
quote: "quoted text",
},
}),
);
});
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendVoice = vi

View File

@ -42,9 +42,20 @@ export async function deliverReplies(params: {
onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
}) {
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } =
params;
const {
replies,
chatId,
runtime,
bot,
replyToMode,
textLimit,
messageThreadId,
linkPreview,
replyQuoteText,
} = params;
const chunkMode = params.chunkMode ?? "length";
const threadParams = buildTelegramThreadParams(messageThreadId);
let hasReplied = false;
@ -97,6 +108,7 @@ export async function deliverReplies(params: {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
replyQuoteText,
messageThreadId,
textMode: "html",
plainText: chunk.text,
@ -140,13 +152,14 @@ export async function deliverReplies(params: {
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
const mediaParams: Record<string, unknown> = {
caption: htmlCaption,
reply_to_message_id: replyToMessageId,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
...buildTelegramSendParams({
replyToMessageId,
messageThreadId,
replyQuoteText,
}),
};
if (threadParams) {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
await withTelegramApiErrorLogging({
operation: "sendAnimation",
@ -207,6 +220,7 @@ export async function deliverReplies(params: {
messageThreadId,
linkPreview,
replyMarkup,
replyQuoteText,
});
// Skip this media item; continue with next.
continue;
@ -391,6 +405,7 @@ async function sendTelegramVoiceFallbackText(opts: {
messageThreadId?: number;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<boolean> {
const chunks = opts.chunkText(opts.text);
let hasReplied = opts.hasReplied;
@ -399,6 +414,7 @@ async function sendTelegramVoiceFallbackText(opts: {
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
replyQuoteText: opts.replyQuoteText,
messageThreadId: opts.messageThreadId,
textMode: "html",
plainText: chunk.text,
@ -415,11 +431,20 @@ async function sendTelegramVoiceFallbackText(opts: {
function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
messageThreadId?: number;
replyQuoteText?: string;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
const params: Record<string, unknown> = {};
const quoteText = opts?.replyQuoteText?.trim();
if (opts?.replyToMessageId) {
params.reply_to_message_id = opts.replyToMessageId;
if (quoteText) {
params.reply_parameters = {
message_id: Math.trunc(opts.replyToMessageId),
quote: quoteText,
};
} else {
params.reply_to_message_id = opts.replyToMessageId;
}
}
if (threadParams) {
params.message_thread_id = threadParams.message_thread_id;
@ -434,6 +459,7 @@ async function sendTelegramText(
runtime: RuntimeEnv,
opts?: {
replyToMessageId?: number;
replyQuoteText?: string;
messageThreadId?: number;
textMode?: "markdown" | "html";
plainText?: string;
@ -443,6 +469,7 @@ async function sendTelegramText(
): Promise<number | undefined> {
const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId,
replyQuoteText: opts?.replyQuoteText,
messageThreadId: opts?.messageThreadId,
});
// Add link_preview_options when link preview is disabled.

View File

@ -150,24 +150,29 @@ export function resolveTelegramReplyId(raw?: string): number | undefined {
return parsed;
}
export function describeReplyTarget(msg: TelegramMessage) {
const reply = msg.reply_to_message;
if (!reply) return null;
// Check for quote (partial message reply)
const quote = (msg as any).quote;
let body = "";
let isQuote = false;
export type TelegramReplyTarget = {
id?: string;
sender: string;
body: string;
kind: "reply" | "quote";
};
if (quote && quote.text) {
// Use quoted text
export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
const quote = msg.quote;
let body = "";
let kind: TelegramReplyTarget["kind"] = "reply";
if (quote?.text) {
body = quote.text.trim();
isQuote = true;
} else {
// Regular reply - use full message
if (body) {
kind = "quote";
}
}
if (!body && reply) {
const replyBody = (reply.text ?? reply.caption ?? "").trim();
body = replyBody;
if (!body) {
if (reply.photo) body = "<media:image>";
else if (reply.video) body = "<media:video>";
@ -179,16 +184,15 @@ export function describeReplyTarget(msg: TelegramMessage) {
}
}
}
if (!body) return null;
const sender = buildSenderName(reply);
const sender = reply ? buildSenderName(reply) : undefined;
const senderLabel = sender ? `${sender}` : "unknown sender";
return {
id: reply.message_id ? String(reply.message_id) : undefined,
id: reply?.message_id ? String(reply.message_id) : undefined,
sender: senderLabel,
body,
isQuote // Add flag to distinguish quote from regular reply
kind,
};
}

View File

@ -1,6 +1,12 @@
import type { Message } from "@grammyjs/types";
export type TelegramMessage = Message;
export type TelegramQuote = {
text?: string;
};
export type TelegramMessage = Message & {
quote?: TelegramQuote;
};
export type TelegramStreamMode = "off" | "partial" | "block";

View File

@ -46,6 +46,8 @@ type TelegramSendOpts = {
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Quote text for Telegram reply_parameters. */
quoteText?: string;
/** Forum topic thread ID (for forum supergroups) */
messageThreadId?: number;
/** Inline keyboard buttons (reply markup). */
@ -198,9 +200,17 @@ export async function sendMessageTelegram(
const messageThreadId =
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
const threadIdParams = buildTelegramThreadParams(messageThreadId);
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
const threadParams: Record<string, unknown> = threadIdParams ? { ...threadIdParams } : {};
const quoteText = opts.quoteText?.trim();
if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
if (quoteText) {
threadParams.reply_parameters = {
message_id: Math.trunc(opts.replyToMessageId),
quote: quoteText,
};
} else {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
}
}
const hasThreadParams = Object.keys(threadParams).length > 0;
const request = createTelegramRetryRunner({