mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 22:54:01 -06:00
fix(telegram): preserve inbound quote attribution
Preserve native Telegram quote attribution in agent-visible input and terminate quotes before following ordinary text. Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
9213792cdf
commit
70f84286c9
@@ -361,15 +361,17 @@ describe("resolveTelegramInboundBody", () => {
|
||||
privateBodyTest(
|
||||
"renders Telegram text entities before building the agent body",
|
||||
{
|
||||
text: "Hello world docs",
|
||||
text: "Hello world\nquoted\nordinary docs",
|
||||
entities: [
|
||||
{ type: "bold", offset: 6, length: 5 },
|
||||
{ type: "text_link", offset: 12, length: 4, url: "https://docs.example" },
|
||||
{ type: "blockquote", offset: 12, length: 6 },
|
||||
{ type: "text_link", offset: 28, length: 4, url: "https://docs.example" },
|
||||
],
|
||||
},
|
||||
(result) => {
|
||||
expect(result?.rawBody).toBe("Hello **world** [docs](https://docs.example)");
|
||||
expect(result?.bodyText).toBe("Hello **world** [docs](https://docs.example)");
|
||||
const expected = "Hello **world**\n> quoted\n\nordinary [docs](https://docs.example)";
|
||||
expect(result?.rawBody).toBe(expected);
|
||||
expect(result?.bodyText).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -47,12 +47,12 @@ import {
|
||||
hasLeadingBotCommandAddressedToOtherBot,
|
||||
hasBotMentionInText,
|
||||
hasBotMention,
|
||||
renderTelegramTextEntities,
|
||||
resolveTelegramPrimaryMedia,
|
||||
resolveTelegramRichMessagePlaceholder,
|
||||
resolveTelegramRichMessageText,
|
||||
} from "./bot/body-helpers.js";
|
||||
import { buildTelegramGroupPeerId, buildTelegramInboundOriginTarget } from "./bot/helpers.js";
|
||||
import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
|
||||
import { resolveTelegramGroupIngestEnabled } from "./group-config-helpers.js";
|
||||
|
||||
@@ -10,10 +10,11 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => {
|
||||
message_id: 2,
|
||||
chat,
|
||||
from: sender,
|
||||
text: "😀 bold\nread docs",
|
||||
text: "😀 quoted\nsecond\nread docs",
|
||||
entities: [
|
||||
{ type: "bold", offset: 3, length: 4 },
|
||||
{ type: "text_link", offset: 13, length: 4, url: "https://docs.example" },
|
||||
{ type: "blockquote", offset: 0, length: 16 },
|
||||
{ type: "bold", offset: 3, length: 6 },
|
||||
{ type: "text_link", offset: 22, length: 4, url: "https://docs.example" },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
@@ -23,8 +24,11 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => {
|
||||
date: 1_700_000_000,
|
||||
chat,
|
||||
from: sender,
|
||||
text: "😀 bold",
|
||||
entities: [{ type: "bold", offset: 3, length: 4 }],
|
||||
text: "😀 quoted\nsecond",
|
||||
entities: [
|
||||
{ type: "blockquote", offset: 0, length: 16 },
|
||||
{ type: "bold", offset: 3, length: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
message_id: 2,
|
||||
@@ -38,9 +42,10 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(context?.ctxPayload.RawBody).toBe("😀 **bold**\nread [docs](https://docs.example)");
|
||||
expect(context?.ctxPayload.BodyForAgent).toBe("😀 **bold**\nread [docs](https://docs.example)");
|
||||
expect(context?.ctxPayload.CommandBody).toBe("😀 **bold**\nread [docs](https://docs.example)");
|
||||
const expected = "> 😀 **quoted**\n> second\n\nread [docs](https://docs.example)";
|
||||
expect(context?.ctxPayload.RawBody).toBe(expected);
|
||||
expect(context?.ctxPayload.BodyForAgent).toBe(expected);
|
||||
expect(context?.ctxPayload.CommandBody).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps ordinary text plain while attributing only the forwarded segment", async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
TelegramMessageContextSessionRuntimeOverrides,
|
||||
TelegramPromptContextEntry,
|
||||
} from "./bot-message-context.types.js";
|
||||
import { renderTelegramTextEntities } from "./bot/body-helpers.js";
|
||||
import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js";
|
||||
import { resolveTelegramPromptMediaPath } from "./prompt-media-path.js";
|
||||
|
||||
type TelegramMentionFacts = NonNullable<
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Message } from "grammy/types";
|
||||
import type { Message, MessageEntity } from "grammy/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTelegramTextParts,
|
||||
joinTelegramTextParts,
|
||||
renderTelegramTextEntities,
|
||||
} from "./body-helpers.js";
|
||||
import { getTelegramTextParts, joinTelegramTextParts } from "./body-helpers.js";
|
||||
import { renderTelegramTextEntities } from "./inbound-text-entities.js";
|
||||
|
||||
function asTelegramMessage(message: unknown): Message {
|
||||
return message as Message;
|
||||
@@ -101,3 +98,36 @@ describe("joinTelegramTextParts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTelegramTextEntities quoted blocks", () => {
|
||||
it.each(["blockquote", "expandable_blockquote"] as const)(
|
||||
"preserves multiline %s entities and nested formatting",
|
||||
(type) => {
|
||||
const text = "Before\n😀 quoted\nsecond link\nAfter";
|
||||
const quote = "😀 quoted\nsecond link";
|
||||
const quoteOffset = text.indexOf(quote);
|
||||
|
||||
const entities: MessageEntity[] = [
|
||||
{ type, offset: quoteOffset, length: quote.length },
|
||||
{ type: "bold", offset: quoteOffset + "😀 ".length, length: "quoted".length },
|
||||
];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe(
|
||||
"Before\n> 😀 **quoted**\n> second link\n\nAfter",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("reopens enclosing formatting across a quote block", () => {
|
||||
const text = "bold before\nquoted\nbold after";
|
||||
const quoteOffset = text.indexOf("quoted");
|
||||
const entities: MessageEntity[] = [
|
||||
{ type: "bold", offset: 0, length: text.length },
|
||||
{ type: "blockquote", offset: quoteOffset, length: "quoted".length },
|
||||
];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe(
|
||||
"**bold before**\n> **quoted**\n\n**bold after**",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { telegramHtmlToPlainTextFallback } from "../format.js";
|
||||
import { renderTelegramTextEntities } from "./inbound-text-entities.js";
|
||||
|
||||
type TelegramMediaMessage = Pick<
|
||||
Message,
|
||||
@@ -350,159 +351,6 @@ export function hasBotMentionInText(text: string, botUsername: string): boolean
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramMarkdownEntity = {
|
||||
type: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
url?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
type TelegramMarkdownBoundary = {
|
||||
open: string;
|
||||
close: string;
|
||||
start: number;
|
||||
end: number;
|
||||
length: number;
|
||||
priority: number;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const TELEGRAM_ENTITY_MARKDOWN_PRIORITY: Record<string, number> = {
|
||||
bold: 10,
|
||||
italic: 20,
|
||||
underline: 30,
|
||||
strikethrough: 40,
|
||||
spoiler: 50,
|
||||
text_link: 60,
|
||||
code: 70,
|
||||
pre: 80,
|
||||
};
|
||||
|
||||
function longestBacktickRun(text: string): number {
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
for (const char of text) {
|
||||
if (char === "`") {
|
||||
current += 1;
|
||||
longest = Math.max(longest, current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
function markdownInlineCodeDelimiters(content: string): [string, string] {
|
||||
const delimiter = "`".repeat(longestBacktickRun(content) + 1);
|
||||
if (content.startsWith(" ") || content.endsWith(" ")) {
|
||||
return [`${delimiter} `, ` ${delimiter}`];
|
||||
}
|
||||
return [delimiter, delimiter];
|
||||
}
|
||||
|
||||
function markdownPreAffixes(entity: TelegramMarkdownEntity, content: string): [string, string] {
|
||||
const language = entity.language?.replace(/[\s`]+/g, "").trim();
|
||||
const fence = "`".repeat(Math.max(3, longestBacktickRun(content) + 1));
|
||||
const opener = language ? `${fence}${language}\n` : `${fence}\n`;
|
||||
const closer = content.endsWith("\n") ? fence : `\n${fence}`;
|
||||
return [opener, closer];
|
||||
}
|
||||
|
||||
function markdownAffixesForTelegramEntity(
|
||||
entity: TelegramMarkdownEntity,
|
||||
content: string,
|
||||
): [string, string] | null {
|
||||
switch (entity.type) {
|
||||
case "bold":
|
||||
return ["**", "**"];
|
||||
case "italic":
|
||||
return ["_", "_"];
|
||||
case "underline":
|
||||
return ["__", "__"];
|
||||
case "strikethrough":
|
||||
return ["~~", "~~"];
|
||||
case "spoiler":
|
||||
return ["||", "||"];
|
||||
case "code":
|
||||
return markdownInlineCodeDelimiters(content);
|
||||
case "pre":
|
||||
return markdownPreAffixes(entity, content);
|
||||
case "text_link":
|
||||
return entity.url ? ["[", `](${entity.url})`] : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTelegramTextEntities(
|
||||
text: string,
|
||||
entities?: TelegramMarkdownEntity[] | null,
|
||||
): string {
|
||||
if (!text || !entities?.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const boundaries = new Map<number, TelegramMarkdownBoundary[]>();
|
||||
const addBoundary = (offset: number, boundary: TelegramMarkdownBoundary) => {
|
||||
boundaries.set(offset, [...(boundaries.get(offset) ?? []), boundary]);
|
||||
};
|
||||
entities.forEach((entity, index) => {
|
||||
if (
|
||||
!Number.isInteger(entity.offset) ||
|
||||
!Number.isInteger(entity.length) ||
|
||||
entity.offset < 0 ||
|
||||
entity.length <= 0 ||
|
||||
entity.offset + entity.length > text.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const content = text.slice(entity.offset, entity.offset + entity.length);
|
||||
const affixes = markdownAffixesForTelegramEntity(entity, content);
|
||||
if (!affixes) {
|
||||
return;
|
||||
}
|
||||
const boundary: TelegramMarkdownBoundary = {
|
||||
open: affixes[0],
|
||||
close: affixes[1],
|
||||
start: entity.offset,
|
||||
end: entity.offset + entity.length,
|
||||
length: entity.length,
|
||||
priority: TELEGRAM_ENTITY_MARKDOWN_PRIORITY[entity.type] ?? 100,
|
||||
index,
|
||||
};
|
||||
addBoundary(boundary.start, boundary);
|
||||
addBoundary(boundary.end, boundary);
|
||||
});
|
||||
|
||||
if (boundaries.size === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = "";
|
||||
for (let offset = 0; offset <= text.length; offset += 1) {
|
||||
const boundary = boundaries.get(offset);
|
||||
if (boundary) {
|
||||
boundary
|
||||
.filter((entity) => entity.end === offset)
|
||||
.toSorted((a, b) => a.length - b.length || b.priority - a.priority || b.index - a.index)
|
||||
.forEach((entity) => {
|
||||
result += entity.close;
|
||||
});
|
||||
boundary
|
||||
.filter((entity) => entity.start === offset)
|
||||
.toSorted((a, b) => b.length - a.length || a.priority - b.priority || a.index - b.index)
|
||||
.forEach((entity) => {
|
||||
result += entity.open;
|
||||
});
|
||||
}
|
||||
if (offset < text.length) {
|
||||
result += text[offset];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type TelegramForwardedContext = {
|
||||
from: string;
|
||||
date?: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Telegram tests cover helpers plugin behavior.
|
||||
import type { MessageEntity } from "grammy/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderTelegramTextEntities } from "./body-helpers.js";
|
||||
import {
|
||||
buildTelegramInboundOriginTarget,
|
||||
buildTelegramRoutingTarget,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resetTelegramForumFlagCacheForTest,
|
||||
shouldUseTelegramDmThreadSession,
|
||||
} from "./helpers.js";
|
||||
import { renderTelegramTextEntities } from "./inbound-text-entities.js";
|
||||
|
||||
type TelegramMessage = Parameters<typeof normalizeForwardedContext>[0];
|
||||
|
||||
@@ -1015,7 +1016,7 @@ describe("renderTelegramTextEntities", () => {
|
||||
{ type: "strikethrough", offset: 17, length: 6 },
|
||||
{ type: "underline", offset: 24, length: 9 },
|
||||
{ type: "spoiler", offset: 34, length: 7 },
|
||||
];
|
||||
] satisfies MessageEntity[];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe(
|
||||
"**bold** _italic_ `code` ~~strike~~ __underline__ ||spoiler||",
|
||||
@@ -1024,14 +1025,18 @@ describe("renderTelegramTextEntities", () => {
|
||||
|
||||
it("renders pre entities with language fences", () => {
|
||||
const text = "const value = 1;";
|
||||
const entities = [{ type: "pre", offset: 0, length: text.length, language: "ts" }];
|
||||
const entities = [
|
||||
{ type: "pre", offset: 0, length: text.length, language: "ts" },
|
||||
] satisfies MessageEntity[];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe("```ts\nconst value = 1;\n```");
|
||||
});
|
||||
|
||||
it("uses a pre fence that cannot close inside content", () => {
|
||||
const text = "before\n```\ninside";
|
||||
const entities = [{ type: "pre", offset: 0, length: text.length, language: "md" }];
|
||||
const entities = [
|
||||
{ type: "pre", offset: 0, length: text.length, language: "md" },
|
||||
] satisfies MessageEntity[];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe("````md\nbefore\n```\ninside\n````");
|
||||
});
|
||||
@@ -1042,7 +1047,7 @@ describe("renderTelegramTextEntities", () => {
|
||||
{ type: "bold", offset: 5, length: 4 },
|
||||
{ type: "text_link", offset: 5, length: 4, url: "https://docs.example" },
|
||||
{ type: "italic", offset: 10, length: 3 },
|
||||
];
|
||||
] satisfies MessageEntity[];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe(
|
||||
"Read **[docs](https://docs.example)** _now_",
|
||||
@@ -1051,7 +1056,7 @@ describe("renderTelegramTextEntities", () => {
|
||||
|
||||
it("uses UTF-16 Telegram offsets", () => {
|
||||
const text = "Hi 😀 bold";
|
||||
const entities = [{ type: "bold", offset: 6, length: 4 }];
|
||||
const entities = [{ type: "bold", offset: 6, length: 4 }] satisfies MessageEntity[];
|
||||
|
||||
expect(renderTelegramTextEntities(text, entities)).toBe("Hi 😀 **bold**");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { MessageEntity } from "grammy/types";
|
||||
|
||||
type TelegramMarkdownBoundary = {
|
||||
open: string;
|
||||
close: string;
|
||||
start: number;
|
||||
end: number;
|
||||
length: number;
|
||||
priority: number;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const TELEGRAM_ENTITY_MARKDOWN_PRIORITY: Partial<Record<MessageEntity["type"], number>> = {
|
||||
blockquote: 0,
|
||||
expandable_blockquote: 0,
|
||||
bold: 10,
|
||||
italic: 20,
|
||||
underline: 30,
|
||||
strikethrough: 40,
|
||||
spoiler: 50,
|
||||
text_link: 60,
|
||||
code: 70,
|
||||
pre: 80,
|
||||
};
|
||||
|
||||
const SPLITTABLE_FORMATTING_ENTITY_TYPES = new Set<MessageEntity["type"]>([
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"spoiler",
|
||||
]);
|
||||
|
||||
function isTelegramBlockquoteEntity(entity: MessageEntity): boolean {
|
||||
return entity.type === "blockquote" || entity.type === "expandable_blockquote";
|
||||
}
|
||||
|
||||
function hasValidTelegramEntityRange(text: string, entity: MessageEntity): boolean {
|
||||
return (
|
||||
Number.isInteger(entity.offset) &&
|
||||
Number.isInteger(entity.length) &&
|
||||
entity.offset >= 0 &&
|
||||
entity.length > 0 &&
|
||||
entity.offset + entity.length <= text.length
|
||||
);
|
||||
}
|
||||
|
||||
function longestBacktickRun(text: string): number {
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
for (const char of text) {
|
||||
if (char === "`") {
|
||||
current += 1;
|
||||
longest = Math.max(longest, current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
function markdownInlineCodeDelimiters(content: string): [string, string] {
|
||||
const delimiter = "`".repeat(longestBacktickRun(content) + 1);
|
||||
if (content.startsWith(" ") || content.endsWith(" ")) {
|
||||
return [`${delimiter} `, ` ${delimiter}`];
|
||||
}
|
||||
return [delimiter, delimiter];
|
||||
}
|
||||
|
||||
function markdownPreAffixes(
|
||||
entity: Extract<MessageEntity, { type: "pre" }>,
|
||||
content: string,
|
||||
): [string, string] {
|
||||
const language = entity.language?.replace(/[\s`]+/g, "").trim();
|
||||
const fence = "`".repeat(Math.max(3, longestBacktickRun(content) + 1));
|
||||
const opener = language ? `${fence}${language}\n` : `${fence}\n`;
|
||||
const closer = content.endsWith("\n") ? fence : `\n${fence}`;
|
||||
return [opener, closer];
|
||||
}
|
||||
|
||||
function markdownAffixesForTelegramEntity(
|
||||
entity: MessageEntity,
|
||||
content: string,
|
||||
): [string, string] | null {
|
||||
switch (entity.type) {
|
||||
case "blockquote":
|
||||
case "expandable_blockquote":
|
||||
return ["> ", ""];
|
||||
case "bold":
|
||||
return ["**", "**"];
|
||||
case "italic":
|
||||
return ["_", "_"];
|
||||
case "underline":
|
||||
return ["__", "__"];
|
||||
case "strikethrough":
|
||||
return ["~~", "~~"];
|
||||
case "spoiler":
|
||||
return ["||", "||"];
|
||||
case "code":
|
||||
return markdownInlineCodeDelimiters(content);
|
||||
case "pre":
|
||||
return markdownPreAffixes(entity, content);
|
||||
case "text_link":
|
||||
return ["[", `](${entity.url})`];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function splitTelegramFormattingAtQuoteEdges(
|
||||
text: string,
|
||||
entity: MessageEntity,
|
||||
quoteEdges: readonly number[],
|
||||
): MessageEntity[] {
|
||||
if (!SPLITTABLE_FORMATTING_ENTITY_TYPES.has(entity.type)) {
|
||||
return [entity];
|
||||
}
|
||||
const entityEnd = entity.offset + entity.length;
|
||||
const interiorEdges = quoteEdges.filter((offset) => entity.offset < offset && offset < entityEnd);
|
||||
if (interiorEdges.length === 0) {
|
||||
return [entity];
|
||||
}
|
||||
|
||||
// Markdown formatting cannot cross a block boundary. Reopen it around each quote.
|
||||
const segments: MessageEntity[] = [];
|
||||
let segmentStart = entity.offset;
|
||||
for (const edge of [...interiorEdges, entityEnd]) {
|
||||
let segmentEnd = edge;
|
||||
while (segmentStart < segmentEnd && /\s/u.test(text.charAt(segmentStart))) {
|
||||
segmentStart += 1;
|
||||
}
|
||||
while (segmentEnd > segmentStart && /\s/u.test(text.charAt(segmentEnd - 1))) {
|
||||
segmentEnd -= 1;
|
||||
}
|
||||
if (segmentStart < segmentEnd) {
|
||||
segments.push({ ...entity, offset: segmentStart, length: segmentEnd - segmentStart });
|
||||
}
|
||||
segmentStart = edge;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function resolveTelegramBlockquoteClose(text: string, start: number, end: number): string {
|
||||
let presentBreaks = 0;
|
||||
let offset = end;
|
||||
while (offset > start && text.charAt(offset - 1) === "\n") {
|
||||
presentBreaks += 1;
|
||||
offset -= text.charAt(offset - 2) === "\r" ? 2 : 1;
|
||||
}
|
||||
offset = end;
|
||||
while (offset < text.length) {
|
||||
if (text.charAt(offset) === "\n") {
|
||||
presentBreaks += 1;
|
||||
offset += 1;
|
||||
} else if (text.charAt(offset) === "\r" && text.charAt(offset + 1) === "\n") {
|
||||
presentBreaks += 1;
|
||||
offset += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const requiredBreaks = end < text.length ? 2 : 1;
|
||||
const missingBreaks = requiredBreaks - presentBreaks;
|
||||
const lineBreak = text.charAt(end) === "\r" || text.charAt(end - 2) === "\r" ? "\r\n" : "\n";
|
||||
return lineBreak.repeat(Math.max(0, missingBreaks));
|
||||
}
|
||||
|
||||
export function renderTelegramTextEntities(
|
||||
text: string,
|
||||
entities?: readonly MessageEntity[] | null,
|
||||
): string {
|
||||
if (!text || !entities?.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const quotedLineStarts = new Set<number>();
|
||||
const quoteEdges = new Set<number>();
|
||||
for (const entity of entities) {
|
||||
if (!isTelegramBlockquoteEntity(entity) || !hasValidTelegramEntityRange(text, entity)) {
|
||||
continue;
|
||||
}
|
||||
const end = entity.offset + entity.length;
|
||||
quoteEdges.add(entity.offset);
|
||||
quoteEdges.add(end);
|
||||
for (let offset = entity.offset + 1; offset < end; offset += 1) {
|
||||
if (text[offset - 1] === "\n") {
|
||||
quotedLineStarts.add(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedQuoteEdges = [...quoteEdges].toSorted((left, right) => left - right);
|
||||
const boundaries = new Map<number, TelegramMarkdownBoundary[]>();
|
||||
const addBoundary = (offset: number, boundary: TelegramMarkdownBoundary) => {
|
||||
const entries = boundaries.get(offset);
|
||||
if (entries) {
|
||||
entries.push(boundary);
|
||||
} else {
|
||||
boundaries.set(offset, [boundary]);
|
||||
}
|
||||
};
|
||||
entities.forEach((entity, index) => {
|
||||
if (!hasValidTelegramEntityRange(text, entity)) {
|
||||
return;
|
||||
}
|
||||
for (const segment of splitTelegramFormattingAtQuoteEdges(text, entity, sortedQuoteEdges)) {
|
||||
const content = text.slice(segment.offset, segment.offset + segment.length);
|
||||
const affixes = markdownAffixesForTelegramEntity(segment, content);
|
||||
if (!affixes) {
|
||||
continue;
|
||||
}
|
||||
const end = segment.offset + segment.length;
|
||||
if (isTelegramBlockquoteEntity(segment)) {
|
||||
affixes[1] = resolveTelegramBlockquoteClose(text, segment.offset, end);
|
||||
}
|
||||
const boundary: TelegramMarkdownBoundary = {
|
||||
open: affixes[0],
|
||||
close: affixes[1],
|
||||
start: segment.offset,
|
||||
end,
|
||||
length: segment.length,
|
||||
priority: TELEGRAM_ENTITY_MARKDOWN_PRIORITY[segment.type] ?? 100,
|
||||
index,
|
||||
};
|
||||
addBoundary(boundary.start, boundary);
|
||||
addBoundary(boundary.end, boundary);
|
||||
}
|
||||
});
|
||||
|
||||
if (boundaries.size === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = "";
|
||||
for (let offset = 0; offset <= text.length; offset += 1) {
|
||||
if (quotedLineStarts.has(offset)) {
|
||||
result += "> ";
|
||||
}
|
||||
const boundary = boundaries.get(offset);
|
||||
if (boundary) {
|
||||
boundary
|
||||
.filter((entity) => entity.end === offset)
|
||||
.toSorted((a, b) => a.length - b.length || b.priority - a.priority || b.index - a.index)
|
||||
.forEach((entity) => {
|
||||
result += entity.close;
|
||||
});
|
||||
boundary
|
||||
.filter((entity) => entity.start === offset)
|
||||
.toSorted((a, b) => b.length - a.length || a.priority - b.priority || a.index - b.index)
|
||||
.forEach((entity) => {
|
||||
result += entity.open;
|
||||
});
|
||||
}
|
||||
if (offset < text.length) {
|
||||
result += text[offset];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user