From 257e767e5beaffa7ed6daf91a758d3b2107d078f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 06:30:14 +0100 Subject: [PATCH] fix(telegram): include native quote excerpts for replies --- CHANGELOG.md | 1 + docs/channels/telegram.md | 2 + .../src/bot-message-context.session.ts | 2 + .../telegram/src/bot-message-dispatch.test.ts | 90 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 43 ++++++++- .../src/bot.create-telegram-bot.test.ts | 6 +- extensions/telegram/src/bot.test.ts | 1 + .../telegram/src/bot/delivery.replies.ts | 74 +++++++++++++-- extensions/telegram/src/bot/delivery.test.ts | 44 +++++++++ extensions/telegram/src/bot/helpers.ts | 20 +++-- .../telegram/src/bot/native-quote.test.ts | 52 +++++++++++ extensions/telegram/src/bot/native-quote.ts | 88 ++++++++++++++++++ 12 files changed, 404 insertions(+), 19 deletions(-) create mode 100644 extensions/telegram/src/bot/native-quote.test.ts create mode 100644 extensions/telegram/src/bot/native-quote.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index abd16fc7de0..47ad480f6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 0e00434cc79..065ce72d8ec 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -489,6 +489,8 @@ curl "https://api.telegram.org/bot/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. diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 1f917884f09..3a6874f3e0d 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -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, diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index db2532901f8..10f4a88d7b4 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 2f9888f9968..98d0467ec3b 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index fd4959867a3..7545e9223c3 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -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 { diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 67355ef5116..2237e4e4860 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -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 () => { diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 59e6605d323..4d43a67d121 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -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; function buildChunkTextResolver(params: { @@ -105,6 +113,46 @@ function filterEmptyTelegramTextChunks(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, diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 1a615abf733..e7f194fa5e3 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -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 diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index c8ee9c661fb..466ac01c403 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -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, }; } diff --git a/extensions/telegram/src/bot/native-quote.test.ts b/extensions/telegram/src/bot/native-quote.test.ts new file mode 100644 index 00000000000..e9e929aa8a5 --- /dev/null +++ b/extensions/telegram/src/bot/native-quote.test.ts @@ -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(); + }); +}); diff --git a/extensions/telegram/src/bot/native-quote.ts b/extensions/telegram/src/bot/native-quote.ts new file mode 100644 index 00000000000..e98d154e349 --- /dev/null +++ b/extensions/telegram/src/bot/native-quote.ts @@ -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; + +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; +}