fix(telegram): include native quote excerpts for replies

This commit is contained in:
Peter Steinberger
2026-04-26 06:30:14 +01:00
parent 639cd50261
commit 257e767e5b
12 changed files with 404 additions and 19 deletions

View File

@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
- Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg.
- Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker.
- Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai.
- Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu.
- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd.
- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd.

View File

@@ -489,6 +489,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `first`
- `all`
When reply threading is enabled and the original Telegram text or caption is available, OpenClaw includes a native Telegram quote excerpt automatically. Telegram caps native quote text at 1024 UTF-16 code units, so longer messages are quoted from the start and fall back to a plain reply if Telegram rejects the quote.
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
</Accordion>

View File

@@ -350,6 +350,8 @@ export async function buildTelegramInboundContextPayload(params: {
ReplyToQuoteText: visibleReplyTarget?.quoteText,
ReplyToQuotePosition: visibleReplyTarget?.quotePosition,
ReplyToQuoteEntities: visibleReplyTarget?.quoteEntities,
ReplyToQuoteSourceText: visibleReplyTarget?.quoteSourceText,
ReplyToQuoteSourceEntities: visibleReplyTarget?.quoteSourceEntities,
ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from,
ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType,
ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId,

View File

@@ -472,6 +472,96 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("passes native quote candidates for current message replies", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext({
msg: {
message_id: 1001,
text: "Original current message",
entities: [{ type: "bold", offset: 0, length: 8 }],
} as unknown as TelegramMessageContext["msg"],
ctxPayload: {
MessageSid: "1001",
} as unknown as TelegramMessageContext["ctxPayload"],
}),
});
expect(createTelegramDraftStream).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ replyToId: "1001" })],
replyQuoteByMessageId: {
"1001": {
text: "Original current message",
position: 0,
entities: [{ type: "bold", offset: 0, length: 8 }],
},
},
}),
);
});
it("passes native quote candidates for explicit reply targets", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello", replyToId: "9001" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext({
ctxPayload: {
ReplyToId: "9001",
ReplyToBody: "trimmed body",
ReplyToQuoteSourceText: " exact reply body",
ReplyToQuoteSourceEntities: [{ type: "italic", offset: 2, length: 5 }],
} as unknown as TelegramMessageContext["ctxPayload"],
}),
});
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ replyToId: "9001" })],
replyQuoteByMessageId: {
"9001": {
text: " exact reply body",
position: 0,
entities: [{ type: "italic", offset: 2, length: 5 }],
},
},
}),
);
});
it("does not build native quote candidates when reply mode is off", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext({
msg: {
message_id: 1001,
text: "Original current message",
} as unknown as TelegramMessageContext["msg"],
ctxPayload: {
MessageSid: "1001",
} as unknown as TelegramMessageContext["ctxPayload"],
}),
replyToMode: "off",
});
expect(deliverReplies.mock.calls[0]?.[0]).not.toHaveProperty("replyQuoteByMessageId.1001");
});
it("keeps answer draft preview for selected quotes when reply mode is off", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -52,7 +52,12 @@ import {
} from "./bot-message-dispatch.runtime.js";
import type { TelegramBotOptions } from "./bot.types.js";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { resolveTelegramReplyId } from "./bot/helpers.js";
import { getTelegramTextParts, resolveTelegramReplyId } from "./bot/helpers.js";
import {
addTelegramNativeQuoteCandidate,
buildTelegramNativeQuoteCandidate,
type TelegramNativeQuoteCandidateByMessageId,
} from "./bot/native-quote.js";
import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { createTelegramDraftStream } from "./draft-stream.js";
@@ -354,8 +359,41 @@ export const dispatchTelegramMessage = async ({
replyQuoteText && !ctxPayload.ReplyToIsExternal
? resolveTelegramReplyId(ctxPayload.ReplyToId)
: undefined;
const replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId = {};
if (replyToMode !== "off") {
if (replyQuoteText && replyQuoteMessageId != null) {
addTelegramNativeQuoteCandidate(replyQuoteByMessageId, replyQuoteMessageId, {
text: replyQuoteText,
...(typeof ctxPayload.ReplyToQuotePosition === "number"
? { position: ctxPayload.ReplyToQuotePosition }
: {}),
...(Array.isArray(ctxPayload.ReplyToQuoteEntities)
? { entities: ctxPayload.ReplyToQuoteEntities }
: {}),
});
}
addTelegramNativeQuoteCandidate(
replyQuoteByMessageId,
ctxPayload.MessageSid ?? msg.message_id,
buildTelegramNativeQuoteCandidate(getTelegramTextParts(msg)),
);
if (!ctxPayload.ReplyToIsExternal && typeof ctxPayload.ReplyToQuoteSourceText === "string") {
addTelegramNativeQuoteCandidate(
replyQuoteByMessageId,
ctxPayload.ReplyToId,
buildTelegramNativeQuoteCandidate({
text: ctxPayload.ReplyToQuoteSourceText,
entities: Array.isArray(ctxPayload.ReplyToQuoteSourceEntities)
? ctxPayload.ReplyToQuoteSourceEntities
: undefined,
}),
);
}
}
const hasNativeQuoteReply =
replyToMode !== "off" && replyQuoteText != null && replyQuoteMessageId != null;
replyToMode !== "off" && Object.keys(replyQuoteByMessageId).length > 0;
const canStreamAnswerDraft =
previewStreamingEnabled &&
!hasNativeQuoteReply &&
@@ -620,6 +658,7 @@ export const dispatchTelegramMessage = async ({
replyQuoteText,
replyQuotePosition,
replyQuoteEntities,
replyQuoteByMessageId,
};
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;

View File

@@ -2688,8 +2688,10 @@ describe("createTelegramBot", () => {
expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1);
for (const [index, call] of sendMessageSpy.mock.calls.entries()) {
const actual = (call[2] as { reply_to_message_id?: number } | undefined)
?.reply_to_message_id;
const params = call[2] as
| { reply_to_message_id?: number; reply_parameters?: { message_id?: number } }
| undefined;
const actual = params?.reply_parameters?.message_id ?? params?.reply_to_message_id;
if (mode === "all" || index === 0) {
expect(actual).toBe(messageId);
} else {

View File

@@ -1522,6 +1522,7 @@ describe("createTelegramBot", () => {
expect(telegramPayload.ReplyToQuoteText).toBe(" summarize this\n");
expect(telegramPayload.ReplyToQuotePosition).toBe(8);
expect(telegramPayload.ReplyToQuoteEntities).toEqual([{ type: "bold", offset: 1, length: 9 }]);
expect(telegramPayload.ReplyToQuoteSourceText).toBe("Can you summarize this?");
});
it("keeps reply linkage while omitting filtered binary reply captions", async () => {

View File

@@ -40,6 +40,7 @@ import {
sendTelegramWithThreadFallback,
} from "./delivery.send.js";
import { resolveTelegramReplyId, type TelegramThreadSpec } from "./helpers.js";
import type { TelegramNativeQuoteCandidateByMessageId } from "./native-quote.js";
import {
markReplyApplied,
resolveReplyToForSend,
@@ -62,6 +63,13 @@ type TelegramReplyChannelData = {
pin?: boolean;
};
type TelegramReplyQuoteForSend = {
messageId?: number;
text?: string;
position?: number;
entities?: unknown[];
};
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
function buildChunkTextResolver(params: {
@@ -105,6 +113,46 @@ function filterEmptyTelegramTextChunks<T extends { text: string }>(chunks: reado
return chunks.filter((chunk) => chunk.text.trim().length > 0);
}
function resolveReplyQuoteForSend(params: {
replyToId?: number;
replyQuoteByMessageId?: TelegramNativeQuoteCandidateByMessageId;
replyQuoteMessageId?: number;
replyQuoteText?: string;
replyQuotePosition?: number;
replyQuoteEntities?: unknown[];
}): TelegramReplyQuoteForSend {
if (params.replyToId != null) {
const mapped = params.replyQuoteByMessageId?.[String(params.replyToId)];
if (mapped?.text) {
const quote: TelegramReplyQuoteForSend = {
messageId: params.replyToId,
text: mapped.text,
};
if (typeof mapped.position === "number") {
quote.position = mapped.position;
}
if (mapped.entities) {
quote.entities = mapped.entities;
}
return quote;
}
}
const quote: TelegramReplyQuoteForSend = {};
if (params.replyQuoteMessageId != null) {
quote.messageId = params.replyQuoteMessageId;
}
if (params.replyQuoteText != null) {
quote.text = params.replyQuoteText;
}
if (params.replyQuotePosition != null) {
quote.position = params.replyQuotePosition;
}
if (params.replyQuoteEntities != null) {
quote.entities = params.replyQuoteEntities;
}
return quote;
}
async function deliverTextReply(params: {
bot: Bot;
chatId: string;
@@ -643,6 +691,8 @@ export async function deliverReplies(params: {
replyQuotePosition?: number;
/** Telegram entities that belong to the selected quote text. */
replyQuoteEntities?: unknown[];
/** Native Telegram quote candidates keyed by message id. */
replyQuoteByMessageId?: TelegramNativeQuoteCandidateByMessageId;
/** Override media loader (tests). */
mediaLoader?: typeof loadWebMedia;
}): Promise<{ delivered: boolean }> {
@@ -707,6 +757,14 @@ export async function deliverReplies(params: {
const rawContent = reply.text || "";
const replyToId =
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
const replyQuote = resolveReplyQuoteForSend({
replyToId,
replyQuoteByMessageId: params.replyQuoteByMessageId,
replyQuoteMessageId: params.replyQuoteMessageId,
replyQuoteText: params.replyQuoteText,
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
});
if (hasMessageSendingHooks) {
const hookResult = await hookRunner?.runMessageSending(
{
@@ -750,10 +808,10 @@ export async function deliverReplies(params: {
chunkText,
replyText: reply.text || "",
replyMarkup,
replyQuoteMessageId: params.replyQuoteMessageId,
replyQuoteText: params.replyQuoteText,
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
replyQuoteMessageId: replyQuote.messageId,
replyQuoteText: replyQuote.text,
replyQuotePosition: replyQuote.position,
replyQuoteEntities: replyQuote.entities,
linkPreview: params.linkPreview,
silent: params.silent,
replyToId,
@@ -775,10 +833,10 @@ export async function deliverReplies(params: {
onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview,
silent: params.silent,
replyQuoteMessageId: params.replyQuoteMessageId,
replyQuoteText: params.replyQuoteText,
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
replyQuoteMessageId: replyQuote.messageId,
replyQuoteText: replyQuote.text,
replyQuotePosition: replyQuote.position,
replyQuoteEntities: replyQuote.entities,
replyMarkup,
replyToId,
replyToMode: params.replyToMode,

View File

@@ -745,6 +745,50 @@ describe("deliverReplies", () => {
);
});
it("uses the native quote candidate that matches each reply target", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [
{ text: "First", replyToId: "500" },
{ text: "Second", replyToId: "501" },
],
runtime,
bot,
replyToMode: "all",
replyQuoteByMessageId: {
"500": { text: "first quote", position: 0 },
"501": { text: "second quote", position: 0 },
},
});
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
reply_parameters: {
message_id: 500,
quote: "first quote",
quote_position: 0,
allow_sending_without_reply: true,
},
}),
);
expect(sendMessage.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({
reply_parameters: {
message_id: 501,
quote: "second quote",
quote_position: 0,
allow_sending_without_reply: true,
},
}),
);
});
it("retries with legacy reply id when native quote parameters are rejected", async () => {
const runtime = createRuntime();
const sendMessage = vi

View File

@@ -382,6 +382,8 @@ export type TelegramReplyTarget = {
quoteEntities?: TelegramTextEntity[];
/** Forward context if the reply target was itself a forwarded message (issue #9619). */
forwardedFrom?: TelegramForwardedContext;
quoteSourceText?: string;
quoteSourceEntities?: TelegramTextEntity[];
};
export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
@@ -401,15 +403,17 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
}
const replyLike = reply ?? externalReply;
const rawReplyText =
replyLike && typeof replyLike.text === "string"
? replyLike.text
: replyLike && typeof replyLike.caption === "string"
? replyLike.caption
: undefined;
const safeReplyText = resolveTelegramTextContent(rawReplyText);
const replyTextParts = replyLike && safeReplyText ? getTelegramTextParts(replyLike) : undefined;
let filteredReplyText = false;
if (!body && replyLike) {
const rawReplyText =
typeof replyLike.text === "string"
? replyLike.text
: typeof replyLike.caption === "string"
? replyLike.caption
: undefined;
const replyBody = resolveTelegramTextContent(rawReplyText).trim();
const replyBody = safeReplyText.trim();
filteredReplyText = hadUnsafeTelegramText(rawReplyText, replyBody);
body = replyBody;
if (!body) {
@@ -453,5 +457,7 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
quotePosition,
quoteEntities,
forwardedFrom,
quoteSourceText: replyTextParts?.text || undefined,
quoteSourceEntities: replyTextParts?.entities,
};
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { buildTelegramNativeQuoteCandidate } from "./native-quote.js";
describe("Telegram native quote candidates", () => {
it("uses a Telegram-safe prefix and preserves leading whitespace", () => {
const candidate = buildTelegramNativeQuoteCandidate({
text: " quoted context\nrest",
maxLength: 10,
});
expect(candidate).toEqual(
expect.objectContaining({
text: " quoted c",
position: 0,
}),
);
expect(candidate).not.toHaveProperty("entities");
});
it("does not split UTF-16 surrogate pairs at the quote cap", () => {
const candidate = buildTelegramNativeQuoteCandidate({
text: `abc😀def`,
maxLength: 4,
});
expect(candidate?.text).toBe("abc");
});
it("slices entities to the quoted prefix", () => {
const candidate = buildTelegramNativeQuoteCandidate({
text: "hello world",
maxLength: 8,
entities: [
{ type: "bold", offset: 0, length: 5 },
{ type: "italic", offset: 6, length: 5 },
],
});
expect(candidate).toEqual({
text: "hello wo",
position: 0,
entities: [
{ type: "bold", offset: 0, length: 5 },
{ type: "italic", offset: 6, length: 2 },
],
});
});
it("omits blank quote candidates", () => {
expect(buildTelegramNativeQuoteCandidate({ text: " \n\t" })).toBeUndefined();
});
});

View File

@@ -0,0 +1,88 @@
import type { TelegramTextEntity } from "./body-helpers.js";
export const TELEGRAM_NATIVE_QUOTE_MAX_LENGTH = 1024;
export type TelegramNativeQuoteCandidate = {
text: string;
position?: number;
entities?: unknown[];
};
export type TelegramNativeQuoteCandidateByMessageId = Record<string, TelegramNativeQuoteCandidate>;
function truncateUtf16Safe(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
let end = Math.max(0, Math.trunc(maxLength));
const lastCodeUnit = value.charCodeAt(end - 1);
if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
end -= 1;
}
return value.slice(0, end);
}
function sliceTelegramEntitiesForQuote(
entities: readonly TelegramTextEntity[] | undefined,
quoteLength: number,
): TelegramTextEntity[] | undefined {
if (!entities?.length || quoteLength <= 0) {
return undefined;
}
const sliced: TelegramTextEntity[] = [];
for (const entity of entities) {
const offset = Number.isFinite(entity.offset) ? Math.trunc(entity.offset) : 0;
const length = Number.isFinite(entity.length) ? Math.trunc(entity.length) : 0;
const start = Math.max(0, offset);
const end = Math.min(quoteLength, offset + length);
if (end <= start) {
continue;
}
sliced.push({
...entity,
offset: start,
length: end - start,
});
}
return sliced.length > 0 ? sliced : undefined;
}
export function buildTelegramNativeQuoteCandidate(params: {
text?: string;
entities?: readonly TelegramTextEntity[];
maxLength?: number;
}): TelegramNativeQuoteCandidate | undefined {
const source = params.text;
if (!source?.trim()) {
return undefined;
}
const maxLength = params.maxLength ?? TELEGRAM_NATIVE_QUOTE_MAX_LENGTH;
const text = truncateUtf16Safe(source, maxLength);
if (!text.trim()) {
return undefined;
}
const candidate: TelegramNativeQuoteCandidate = {
text,
position: 0,
};
const entities = sliceTelegramEntitiesForQuote(params.entities, text.length);
if (entities) {
candidate.entities = entities;
}
return candidate;
}
export function addTelegramNativeQuoteCandidate(
target: TelegramNativeQuoteCandidateByMessageId,
messageId: string | number | undefined,
candidate: TelegramNativeQuoteCandidate | undefined,
): void {
if (messageId == null || !candidate) {
return;
}
const key = String(messageId).trim();
if (!key || target[key]) {
return;
}
target[key] = candidate;
}