mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 06:41:22 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user