feat: Add support for Telegram quote (partial message replies) (#2900)

* feat: Add support for Telegram quote (partial message replies)

- Enhanced describeReplyTarget() to detect and extract quoted text from msg.quote
- Updated reply formatting to distinguish between full message replies and quotes
- Added isQuote flag to replyTarget object for proper identification
- Quote replies show as [Quoting user] "quoted text" [/Quoting]
- Regular replies unchanged: [Replying to user] full message [/Replying]

Resolves need for partial message reply support in Telegram Bot API.
Backward compatible with existing reply functionality.

* updating references

* Mac: finish Moltbot rename

* Mac: finish Moltbot rename (paths)

* fix(macOS): rename Clawdbot directories to Moltbot for naming consistency

Directory renames:
- apps/macos/Sources/Clawdbot → Moltbot
- apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery
- apps/macos/Sources/ClawdbotIPC → MoltbotIPC
- apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI
- apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol
- apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests
- apps/shared/ClawdbotKit → MoltbotKit
- apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot*
- apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests

Resource renames:
- Clawdbot.icns → Moltbot.icns

Code fixes:
- Update Package.swift paths to reference Moltbot* directories
- Fix clawdbot* → moltbot* symbol references in Swift code:
  - clawdbotManagedPaths → moltbotManagedPaths
  - clawdbotExecutable → moltbotExecutable
  - clawdbotCommand → moltbotCommand
  - clawdbotNodeCommand → moltbotNodeCommand
  - clawdbotOAuthDirEnv → moltbotOAuthDirEnv
  - clawdbotSelectSettingsTab → moltbotSelectSettingsTab

* fix: update remaining ClawdbotKit path references to MoltbotKit

- scripts/bundle-a2ui.sh: A2UI_APP_DIR path
- package.json: format:swift and protocol:check paths
- scripts/protocol-gen-swift.ts: output paths
- .github/dependabot.yml: directory path and comment
- .gitignore: build cache paths
- .swiftformat: exclusion paths
- .swiftlint.yml: exclusion path
- apps/android/app/build.gradle.kts: assets.srcDir path
- apps/ios/project.yml: package path
- apps/ios/README.md: documentation reference
- docs/concepts/typebox.md: documentation reference
- apps/shared/MoltbotKit/Package.swift: fix argument order

* chore: update Package.resolved after dependency resolution

* fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path

The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR
which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR
constant for the current Moltbot path and updated the test to use it.

* fix: finish Moltbot macOS rename (#2844) (thanks @fal3)

* Extensions: use workspace moltbot in memory-core

* fix(security): recognize Venice-style claude-opus-45 as top-tier model

The security audit was incorrectly flagging venice/claude-opus-45 as
'Below Claude 4.5' because the regex expected -4-5 (with dash) but
Venice uses -45 (without dash between 4 and 5).

Updated isClaude45OrHigher() regex to match both formats.
Added test case to prevent regression.

* Branding: update bot.molt bundle IDs + launchd labels

* Branding: remove legacy android packages

* fix: wire telegram quote support (#2900)

Co-authored-by: aduk059 <aduk059@users.noreply.github.com>

* fix: support Telegram quote replies (#2900) (thanks @aduk059)

---------

Co-authored-by: Gustavo Madeira Santana <gumadeiras@users.noreply.github.com>
Co-authored-by: Shadow <shadow@clawd.bot>
Co-authored-by: Alex Fallah <alexfallah7@gmail.com>
Co-authored-by: Josh Palmer <joshp123@users.noreply.github.com>
Co-authored-by: jonisjongithub <jonisjongithub@users.noreply.github.com>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
Co-authored-by: aduk059 <aduk059@users.noreply.github.com>
This commit is contained in:
A. Duk
2026-01-28 00:59:24 +04:00
committed by GitHub
parent 9ec4c619e0
commit 284b54af42
17 changed files with 326 additions and 30 deletions

View File

@@ -480,9 +480,13 @@ export const buildTelegramMessageContext = async ({
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
const replySuffix = replyTarget
? `\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}${
@@ -565,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,11 +42,21 @@ 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;
const chunkText = (markdown: string) => {
const markdownChunks =
@@ -97,6 +107,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 +151,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 +219,7 @@ export async function deliverReplies(params: {
messageThreadId,
linkPreview,
replyMarkup,
replyQuoteText,
});
// Skip this media item; continue with next.
continue;
@@ -391,6 +404,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 +413,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 +430,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 +458,7 @@ async function sendTelegramText(
runtime: RuntimeEnv,
opts?: {
replyToMessageId?: number;
replyQuoteText?: string;
messageThreadId?: number;
textMode?: "markdown" | "html";
plainText?: string;
@@ -443,6 +468,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,28 +150,49 @@ export function resolveTelegramReplyId(raw?: string): number | undefined {
return parsed;
}
export function describeReplyTarget(msg: TelegramMessage) {
export type TelegramReplyTarget = {
id?: string;
sender: string;
body: string;
kind: "reply" | "quote";
};
export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null {
const reply = msg.reply_to_message;
if (!reply) return null;
const replyBody = (reply.text ?? reply.caption ?? "").trim();
let body = replyBody;
if (!body) {
if (reply.photo) body = "<media:image>";
else if (reply.video) body = "<media:video>";
else if (reply.audio || reply.voice) body = "<media:audio>";
else if (reply.document) body = "<media:document>";
else {
const locationData = extractTelegramLocation(reply);
if (locationData) body = formatLocationText(locationData);
const quote = msg.quote;
let body = "";
let kind: TelegramReplyTarget["kind"] = "reply";
if (quote?.text) {
body = quote.text.trim();
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>";
else if (reply.audio || reply.voice) body = "<media:audio>";
else if (reply.document) body = "<media:document>";
else {
const locationData = extractTelegramLocation(reply);
if (locationData) body = formatLocationText(locationData);
}
}
}
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,
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({