mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(telegram): include native quote excerpts for replies
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
52
extensions/telegram/src/bot/native-quote.test.ts
Normal file
52
extensions/telegram/src/bot/native-quote.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
88
extensions/telegram/src/bot/native-quote.ts
Normal file
88
extensions/telegram/src/bot/native-quote.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user