diff --git a/README.md b/README.md index c1d0443..317a50c 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel | `allowAllUsers` | `true` 仅调试 | | `maxMessageLength` | 默认 4096 | | `pollingTimeoutSec` | 默认 30 | +| `rendering` | `rich`(默认,原生 Rich Message)或 `html`(旧 Markdown→HTML 兼容) | 若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。 diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 6bd93a0..0d6655a 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -8,6 +8,7 @@ export interface TelegramBridgeOptions { sleep?: (ms: number) => Promise; maxMessageLength?: number; pollingTimeoutSec?: number; + rendering?: 'rich' | 'html'; } export declare class TelegramBridge { private readonly ctx; @@ -17,6 +18,7 @@ export declare class TelegramBridge { private readonly client; private readonly sleep; private readonly maxMessageLength; + private renderingMode; private readonly bindings; private readonly pickers; /** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */ @@ -50,5 +52,6 @@ export declare class TelegramBridge { private interruptibleSleep; private onSessionEvent; private deliver; + private deliverHtml; private redact; } diff --git a/lib/bridge.js b/lib/bridge.js index 2516a0a..177c25f 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -5,6 +5,7 @@ import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForW import { TelegramClient, } from './client.js'; import { MSG, LAST_CB, parseCommand } from './commands.js'; import { markdownToHtml, splitMessage } from './format.js'; +import { splitRichMarkdown } from './rich-format.js'; import { formatLastTurn, loadLastTurn } from './history.js'; import { describeAgent, displayLabel } from './label.js'; import { formatModel, loadSessionModels, selectSessionModel, } from './models.js'; @@ -28,6 +29,10 @@ function contentToText(content) { .map((block) => block.text) .join(''); } +function isRichUnsupportedError(err) { + const message = err instanceof Error ? err.message : String(err); + return /method not found|unknown method|not found|404|bad request|can't parse|rich message/i.test(message); +} export class TelegramBridge { ctx; token; @@ -36,6 +41,7 @@ export class TelegramBridge { client; sleep; maxMessageLength; + renderingMode; bindings = new Map(); pickers = new Map(); /** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */ @@ -55,6 +61,7 @@ export class TelegramBridge { }); this.sleep = options.sleep ?? defaultSleep; this.maxMessageLength = options.maxMessageLength ?? 4096; + this.renderingMode = options.rendering === 'html' ? 'html' : 'rich'; } start() { this.disposeSessionListener?.(); @@ -603,6 +610,29 @@ export class TelegramBridge { } } async deliver(chatId, markdown) { + if (this.renderingMode === 'html') { + await this.deliverHtml(chatId, markdown); + return; + } + let sentAny = false; + try { + const chunks = splitRichMarkdown(markdown); + for (const chunk of chunks) { + await this.client.sendRichMessage(chatId, chunk); + sentAny = true; + } + } + catch (err) { + if (!sentAny && isRichUnsupportedError(err)) { + this.ctx.logger.warn('dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering'); + this.renderingMode = 'html'; + await this.deliverHtml(chatId, markdown); + return; + } + this.ctx.logger.error(this.redact(err)); + } + } + async deliverHtml(chatId, markdown) { const chunks = splitMessage(markdown, this.maxMessageLength); for (const chunk of chunks) { const html = markdownToHtml(chunk); diff --git a/lib/client.d.ts b/lib/client.d.ts index 6448848..54c4177 100644 --- a/lib/client.d.ts +++ b/lib/client.d.ts @@ -39,6 +39,10 @@ export interface TelegramBotCommand { command: string; description: string; } +export interface TelegramInputRichMessage { + markdown: string; + skip_entity_detection?: boolean; +} export interface TelegramClientOptions { fetch?: typeof fetch; baseUrl?: string; @@ -48,6 +52,7 @@ export interface TelegramClientLike { getMe(): Promise; getUpdates(offset?: number): Promise; sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise; + sendRichMessage(chatId: number, markdown: string): Promise; sendChatAction(chatId: number, action: string): Promise; answerCallbackQuery(callbackQueryId: string, text?: string): Promise; setMyCommands(commands: TelegramBotCommand[]): Promise; @@ -63,6 +68,7 @@ export declare class TelegramClient implements TelegramClientLike { getMe(): Promise; getUpdates(offset?: number): Promise; sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise; + sendRichMessage(chatId: number, markdown: string): Promise; sendChatAction(chatId: number, action: string): Promise; answerCallbackQuery(callbackQueryId: string, text?: string): Promise; setMyCommands(commands: TelegramBotCommand[]): Promise; diff --git a/lib/client.js b/lib/client.js index fccb208..c0e5ea9 100644 --- a/lib/client.js +++ b/lib/client.js @@ -82,6 +82,16 @@ export class TelegramClient { } return this.call('sendMessage', body); } + async sendRichMessage(chatId, markdown) { + const body = { + chat_id: chatId, + rich_message: { + markdown, + skip_entity_detection: true, + }, + }; + return this.call('sendRichMessage', body); + } async sendChatAction(chatId, action) { return this.call('sendChatAction', { chat_id: chatId, action }); } diff --git a/lib/index.d.ts b/lib/index.d.ts index 28c74cd..1636ab1 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -9,6 +9,7 @@ export interface TelegramChannelConfig { allowAllUsers?: boolean; maxMessageLength?: number; pollingTimeoutSec?: number; + rendering?: string; } export declare const Config: Schema; export declare function apply(ctx: Context, config: TelegramChannelConfig): void; diff --git a/lib/index.js b/lib/index.js index b85e792..d905698 100644 --- a/lib/index.js +++ b/lib/index.js @@ -9,6 +9,7 @@ export const Config = Schema.object({ allowAllUsers: Schema.boolean().default(false), maxMessageLength: Schema.number().default(4096), pollingTimeoutSec: Schema.number().default(30), + rendering: Schema.string().default('rich'), }); function resolveAllowedUserIds(config) { if (config.allowedUserIds && config.allowedUserIds.length > 0) { @@ -36,6 +37,7 @@ export function apply(ctx, config) { allowAllUsers: config.allowAllUsers ?? false, maxMessageLength: config.maxMessageLength ?? 4096, pollingTimeoutSec: config.pollingTimeoutSec ?? 30, + rendering: config.rendering === 'html' ? 'html' : 'rich', }); ctx.effect(() => { bridge.start(); diff --git a/lib/rich-format.d.ts b/lib/rich-format.d.ts new file mode 100644 index 0000000..38235d0 --- /dev/null +++ b/lib/rich-format.d.ts @@ -0,0 +1,3 @@ +export declare const RICH_MESSAGE_MAX_CHARS = 32768; +export declare const RICH_MESSAGE_MAX_BLOCKS = 500; +export declare function splitRichMarkdown(markdown: string): string[]; diff --git a/lib/rich-format.js b/lib/rich-format.js new file mode 100644 index 0000000..4d8f180 --- /dev/null +++ b/lib/rich-format.js @@ -0,0 +1,224 @@ +export const RICH_MESSAGE_MAX_CHARS = 32768; +export const RICH_MESSAGE_MAX_BLOCKS = 500; +function normalizeTelegramNativeMarkdown(markdown) { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + let fence; + let displayMath = false; + const hasClosingDisplayMathDelimiter = (source, index) => { + for (let i = index + 1; i < source.length; i++) { + if (source[i].trim() === '$$') + return true; + if (/^ {0,3}(`{3,}|~{3,})/.test(source[i])) + return false; + } + return false; + }; + return lines + .map((line, index) => { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/); + const inFence = fence !== undefined; + if (!inFence && line.trim() === '$$') { + if (displayMath) { + displayMath = false; + return '```'; + } + if (hasClosingDisplayMathDelimiter(lines, index)) { + displayMath = true; + return '```math'; + } + } + if (displayMath) + return line; + if (!inFence && fenceMatch) { + const markerText = fenceMatch[1]; + fence = { + marker: markerText[0], + length: markerText.length, + }; + return line; + } + if (inFence + && new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) { + fence = undefined; + return line; + } + return line; + }) + .join('\n'); +} +function countTelegramNativeMarkdownBlocks(block) { + if (/^ {0,3}(`{3,}|~{3,})/.test(block)) + return 1; + const lines = block.split('\n').filter((line) => line.trim().length > 0); + if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) { + return Math.max(1, lines.length); + } + return 1; +} +function splitTelegramNativeMarkdownBlocks(markdown) { + const blocks = []; + const current = []; + let fence; + const flush = () => { + if (current.length === 0) + return; + blocks.push(current.join('\n')); + current.length = 0; + }; + for (const line of markdown.split('\n')) { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (!fence && line.trim().length === 0) { + flush(); + continue; + } + current.push(line); + if (!fence && fenceMatch) { + const markerText = fenceMatch[1]; + fence = { marker: markerText[0], length: markerText.length }; + continue; + } + if (fence + && new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) { + fence = undefined; + } + } + flush(); + return blocks; +} +function splitTelegramNativeMarkdownCountedBlocks(block) { + if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) { + return [block]; + } + const chunks = []; + let current = []; + let fence; + for (const line of block.split('\n')) { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) { + chunks.push(current.join('\n')); + current = []; + } + current.push(line); + if (!fence && fenceMatch) { + const markerText = fenceMatch[1]; + fence = { marker: markerText[0], length: markerText.length }; + continue; + } + if (fence + && new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) { + fence = undefined; + } + } + if (current.length > 0) + chunks.push(current.join('\n')); + return chunks; +} +function findTelegramNativeMarkdownSplitIndex(text, hardLimit = RICH_MESSAGE_MAX_CHARS) { + const paragraphIndex = text.lastIndexOf('\n\n', hardLimit); + if (paragraphIndex > 0) + return paragraphIndex + 2; + const lineIndex = text.lastIndexOf('\n', hardLimit); + if (lineIndex > 0) + return lineIndex + 1; + const spaceIndex = text.lastIndexOf(' ', hardLimit); + if (spaceIndex > 0) + return spaceIndex + 1; + return hardLimit; +} +function splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, wrap) { + const chunks = []; + let remaining = content; + while (remaining.length > maxContentLength) { + const window = remaining.slice(0, maxContentLength + 1); + const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength); + chunks.push(wrap(remaining.slice(0, splitIndex))); + remaining = remaining.slice(splitIndex); + } + if (remaining.length > 0) + chunks.push(wrap(remaining)); + return chunks; +} +function splitTelegramNativeMarkdownLongFenceBlock(block) { + const lines = block.split('\n'); + const opening = lines[0] ?? ''; + const closing = lines[lines.length - 1] ?? ''; + const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/); + if (!openingMatch || !closing || lines.length < 2) + return undefined; + const markerText = openingMatch[1] ?? '```'; + const marker = markerText[0]; + if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) { + return undefined; + } + const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2; + if (maxContentLength <= 0) + return undefined; + const content = lines.slice(1, -1).join('\n'); + return splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, (chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`); +} +function splitTelegramNativeMarkdownLongWrappedInlineBlock(block) { + const delimiter = ['**', '__', '~~', '`', '*', '_'].find((candidate) => block.startsWith(candidate) + && block.endsWith(candidate) + && block.length > candidate.length * 2); + if (!delimiter) + return undefined; + const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2; + if (maxContentLength <= 0) + return undefined; + return splitTelegramNativeMarkdownWrappedContent(block.slice(delimiter.length, -delimiter.length), maxContentLength, (chunk) => `${delimiter}${chunk}${delimiter}`); +} +function splitTelegramNativeMarkdownLongPlainBlock(block) { + const chunks = []; + let remaining = block; + while (remaining.length > RICH_MESSAGE_MAX_CHARS) { + const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1); + const splitIndex = findTelegramNativeMarkdownSplitIndex(window); + chunks.push(remaining.slice(0, splitIndex).trimEnd()); + remaining = remaining.slice(splitIndex).trimStart(); + } + if (remaining.length > 0) + chunks.push(remaining); + return chunks; +} +function splitTelegramNativeMarkdownLongBlock(block) { + return (splitTelegramNativeMarkdownLongFenceBlock(block) + ?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block) + ?? splitTelegramNativeMarkdownLongPlainBlock(block)); +} +export function splitRichMarkdown(markdown) { + const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown); + if (normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS + && countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS) { + return [normalizedMarkdown]; + } + const chunks = []; + let current = ''; + let currentBlockCount = 0; + for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) { + for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) { + const blockCount = countTelegramNativeMarkdownBlocks(block); + const candidate = current ? `${current}\n\n${block}` : block; + const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS; + const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS; + if (!exceedsChars && !exceedsBlocks) { + current = candidate; + currentBlockCount += blockCount; + continue; + } + if (current) + chunks.push(current.trimEnd()); + if (block.length <= RICH_MESSAGE_MAX_CHARS + && blockCount <= RICH_MESSAGE_MAX_BLOCKS) { + current = block; + currentBlockCount = blockCount; + continue; + } + chunks.push(...splitTelegramNativeMarkdownLongBlock(block)); + current = ''; + currentBlockCount = 0; + } + } + if (current) + chunks.push(current.trimEnd()); + return chunks; +} diff --git a/src/bridge.ts b/src/bridge.ts index 5c17d99..c835c0f 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -22,6 +22,7 @@ import { } from './client.js' import { MSG, LAST_CB, parseCommand } from './commands.js' import { markdownToHtml, splitMessage } from './format.js' +import { splitRichMarkdown } from './rich-format.js' import { formatLastTurn, loadLastTurn } from './history.js' import { describeAgent, displayLabel } from './label.js' import { @@ -39,6 +40,7 @@ export interface TelegramBridgeOptions { sleep?: (ms: number) => Promise maxMessageLength?: number pollingTimeoutSec?: number + rendering?: 'rich' | 'html' } /** chatId → bound live session id (string form of SessionId) */ @@ -84,6 +86,11 @@ function contentToText(content: readonly ContentBlock[]): string { .join('') } +function isRichUnsupportedError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return /method not found|unknown method|not found|404|bad request|can't parse|rich message/i.test(message) +} + export class TelegramBridge { private readonly ctx: Context private readonly token: string @@ -92,6 +99,7 @@ export class TelegramBridge { private readonly client: TelegramClientLike private readonly sleep: (ms: number) => Promise private readonly maxMessageLength: number + private renderingMode: 'rich' | 'html' private readonly bindings = new Map() private readonly pickers = new Map() @@ -113,6 +121,7 @@ export class TelegramBridge { }) this.sleep = options.sleep ?? defaultSleep this.maxMessageLength = options.maxMessageLength ?? 4096 + this.renderingMode = options.rendering === 'html' ? 'html' : 'rich' } start(): void { @@ -680,6 +689,32 @@ export class TelegramBridge { } private async deliver(chatId: number, markdown: string): Promise { + if (this.renderingMode === 'html') { + await this.deliverHtml(chatId, markdown) + return + } + + let sentAny = false + try { + const chunks = splitRichMarkdown(markdown) + for (const chunk of chunks) { + await this.client.sendRichMessage(chatId, chunk) + sentAny = true + } + } catch (err) { + if (!sentAny && isRichUnsupportedError(err)) { + this.ctx.logger.warn( + 'dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering', + ) + this.renderingMode = 'html' + await this.deliverHtml(chatId, markdown) + return + } + this.ctx.logger.error(this.redact(err)) + } + } + + private async deliverHtml(chatId: number, markdown: string): Promise { const chunks = splitMessage(markdown, this.maxMessageLength) for (const chunk of chunks) { const html = markdownToHtml(chunk) diff --git a/src/client.ts b/src/client.ts index ff37acc..757d113 100644 --- a/src/client.ts +++ b/src/client.ts @@ -49,6 +49,11 @@ export interface TelegramBotCommand { description: string } +export interface TelegramInputRichMessage { + markdown: string + skip_entity_detection?: boolean +} + export interface TelegramClientOptions { fetch?: typeof fetch baseUrl?: string @@ -64,6 +69,7 @@ export interface TelegramClientLike { parseMode?: string, replyMarkup?: InlineKeyboardMarkup, ): Promise + sendRichMessage(chatId: number, markdown: string): Promise sendChatAction(chatId: number, action: string): Promise answerCallbackQuery(callbackQueryId: string, text?: string): Promise setMyCommands(commands: TelegramBotCommand[]): Promise @@ -167,6 +173,17 @@ export class TelegramClient implements TelegramClientLike { return this.call('sendMessage', body) } + async sendRichMessage(chatId: number, markdown: string): Promise { + const body: Record = { + chat_id: chatId, + rich_message: { + markdown, + skip_entity_detection: true, + }, + } + return this.call('sendRichMessage', body) + } + async sendChatAction(chatId: number, action: string): Promise { return this.call('sendChatAction', { chat_id: chatId, action }) } diff --git a/src/index.ts b/src/index.ts index 51e05d4..c1f4437 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export interface TelegramChannelConfig { allowAllUsers?: boolean maxMessageLength?: number pollingTimeoutSec?: number + rendering?: string } export const Config: Schema = Schema.object({ @@ -20,6 +21,7 @@ export const Config: Schema = Schema.object({ allowAllUsers: Schema.boolean().default(false), maxMessageLength: Schema.number().default(4096), pollingTimeoutSec: Schema.number().default(30), + rendering: Schema.string().default('rich'), }) function resolveAllowedUserIds(config: TelegramChannelConfig): number[] { @@ -51,6 +53,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void { allowAllUsers: config.allowAllUsers ?? false, maxMessageLength: config.maxMessageLength ?? 4096, pollingTimeoutSec: config.pollingTimeoutSec ?? 30, + rendering: config.rendering === 'html' ? 'html' : 'rich', }) ctx.effect(() => { bridge.start() diff --git a/src/rich-format.ts b/src/rich-format.ts new file mode 100644 index 0000000..04279f4 --- /dev/null +++ b/src/rich-format.ts @@ -0,0 +1,288 @@ +export const RICH_MESSAGE_MAX_CHARS = 32768 +export const RICH_MESSAGE_MAX_BLOCKS = 500 + +function normalizeTelegramNativeMarkdown(markdown: string): string { + const lines = markdown.replace(/\r\n/g, '\n').split('\n') + let fence: { marker: '`' | '~'; length: number } | undefined + let displayMath = false + + const hasClosingDisplayMathDelimiter = (source: string[], index: number): boolean => { + for (let i = index + 1; i < source.length; i++) { + if (source[i]!.trim() === '$$') return true + if (/^ {0,3}(`{3,}|~{3,})/.test(source[i]!)) return false + } + return false + } + + return lines + .map((line, index) => { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/) + const inFence = fence !== undefined + + if (!inFence && line.trim() === '$$') { + if (displayMath) { + displayMath = false + return '```' + } + if (hasClosingDisplayMathDelimiter(lines, index)) { + displayMath = true + return '```math' + } + } + + if (displayMath) return line + + if (!inFence && fenceMatch) { + const markerText = fenceMatch[1]! + fence = { + marker: markerText[0] as '`' | '~', + length: markerText.length, + } + return line + } + + if ( + inFence + && new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line) + ) { + fence = undefined + return line + } + + return line + }) + .join('\n') +} + +function countTelegramNativeMarkdownBlocks(block: string): number { + if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1 + const lines = block.split('\n').filter((line) => line.trim().length > 0) + if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) { + return Math.max(1, lines.length) + } + return 1 +} + +function splitTelegramNativeMarkdownBlocks(markdown: string): string[] { + const blocks: string[] = [] + const current: string[] = [] + let fence: { marker: '`' | '~'; length: number } | undefined + + const flush = (): void => { + if (current.length === 0) return + blocks.push(current.join('\n')) + current.length = 0 + } + + for (const line of markdown.split('\n')) { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/) + + if (!fence && line.trim().length === 0) { + flush() + continue + } + + current.push(line) + + if (!fence && fenceMatch) { + const markerText = fenceMatch[1]! + fence = { marker: markerText[0] as '`' | '~', length: markerText.length } + continue + } + + if ( + fence + && new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line) + ) { + fence = undefined + } + } + + flush() + return blocks +} + +function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] { + if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) { + return [block] + } + + const chunks: string[] = [] + let current: string[] = [] + let fence: { marker: '`' | '~'; length: number } | undefined + + for (const line of block.split('\n')) { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/) + + if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) { + chunks.push(current.join('\n')) + current = [] + } + + current.push(line) + + if (!fence && fenceMatch) { + const markerText = fenceMatch[1]! + fence = { marker: markerText[0] as '`' | '~', length: markerText.length } + continue + } + + if ( + fence + && new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line) + ) { + fence = undefined + } + } + + if (current.length > 0) chunks.push(current.join('\n')) + return chunks +} + +function findTelegramNativeMarkdownSplitIndex( + text: string, + hardLimit = RICH_MESSAGE_MAX_CHARS, +): number { + const paragraphIndex = text.lastIndexOf('\n\n', hardLimit) + if (paragraphIndex > 0) return paragraphIndex + 2 + const lineIndex = text.lastIndexOf('\n', hardLimit) + if (lineIndex > 0) return lineIndex + 1 + const spaceIndex = text.lastIndexOf(' ', hardLimit) + if (spaceIndex > 0) return spaceIndex + 1 + return hardLimit +} + +function splitTelegramNativeMarkdownWrappedContent( + content: string, + maxContentLength: number, + wrap: (chunk: string) => string, +): string[] { + const chunks: string[] = [] + let remaining = content + + while (remaining.length > maxContentLength) { + const window = remaining.slice(0, maxContentLength + 1) + const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength) + chunks.push(wrap(remaining.slice(0, splitIndex))) + remaining = remaining.slice(splitIndex) + } + + if (remaining.length > 0) chunks.push(wrap(remaining)) + return chunks +} + +function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | undefined { + const lines = block.split('\n') + const opening = lines[0] ?? '' + const closing = lines[lines.length - 1] ?? '' + const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/) + + if (!openingMatch || !closing || lines.length < 2) return undefined + + const markerText = openingMatch[1] ?? '```' + const marker = markerText[0] as '`' | '~' + if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) { + return undefined + } + + const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2 + if (maxContentLength <= 0) return undefined + + const content = lines.slice(1, -1).join('\n') + return splitTelegramNativeMarkdownWrappedContent( + content, + maxContentLength, + (chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`, + ) +} + +function splitTelegramNativeMarkdownLongWrappedInlineBlock(block: string): string[] | undefined { + const delimiter = ['**', '__', '~~', '`', '*', '_'].find( + (candidate) => + block.startsWith(candidate) + && block.endsWith(candidate) + && block.length > candidate.length * 2, + ) + + if (!delimiter) return undefined + + const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2 + if (maxContentLength <= 0) return undefined + + return splitTelegramNativeMarkdownWrappedContent( + block.slice(delimiter.length, -delimiter.length), + maxContentLength, + (chunk) => `${delimiter}${chunk}${delimiter}`, + ) +} + +function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] { + const chunks: string[] = [] + let remaining = block + + while (remaining.length > RICH_MESSAGE_MAX_CHARS) { + const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1) + const splitIndex = findTelegramNativeMarkdownSplitIndex(window) + chunks.push(remaining.slice(0, splitIndex).trimEnd()) + remaining = remaining.slice(splitIndex).trimStart() + } + + if (remaining.length > 0) chunks.push(remaining) + return chunks +} + +function splitTelegramNativeMarkdownLongBlock(block: string): string[] { + return ( + splitTelegramNativeMarkdownLongFenceBlock(block) + ?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block) + ?? splitTelegramNativeMarkdownLongPlainBlock(block) + ) +} + +export function splitRichMarkdown(markdown: string): string[] { + const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown) + + if ( + normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS + && countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS + ) { + return [normalizedMarkdown] + } + + const chunks: string[] = [] + let current = '' + let currentBlockCount = 0 + + for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) { + for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) { + const blockCount = countTelegramNativeMarkdownBlocks(block) + const candidate = current ? `${current}\n\n${block}` : block + const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS + const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS + + if (!exceedsChars && !exceedsBlocks) { + current = candidate + currentBlockCount += blockCount + continue + } + + if (current) chunks.push(current.trimEnd()) + + if ( + block.length <= RICH_MESSAGE_MAX_CHARS + && blockCount <= RICH_MESSAGE_MAX_BLOCKS + ) { + current = block + currentBlockCount = blockCount + continue + } + + chunks.push(...splitTelegramNativeMarkdownLongBlock(block)) + current = '' + currentBlockCount = 0 + } + } + + if (current) chunks.push(current.trimEnd()) + return chunks +} diff --git a/tests/client.test.ts b/tests/client.test.ts index 61458ba..7970bbf 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -51,3 +51,24 @@ test('sendMessage can include reply_markup', async () => { inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]], }) }) + +test('sendRichMessage posts native rich markdown body', async () => { + const calls: Array<{ url: string; body: Record }> = [] + const fetchImpl: typeof fetch = async (input, init) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }) + return new Response(JSON.stringify({ + ok: true, + result: { message_id: 2, date: 0, chat: { id: 1, type: 'private' }, text: '# hi' }, + }), { status: 200 }) + } + const client = new TelegramClient('TOK', { fetch: fetchImpl }) + await client.sendRichMessage(1, '# hi\n\n- a\n- b') + assert.match(calls[0]!.url, /\/botTOK\/sendRichMessage$/) + assert.deepEqual(calls[0]!.body, { + chat_id: 1, + rich_message: { + markdown: '# hi\n\n- a\n- b', + skip_entity_detection: true, + }, + }) +}) diff --git a/tests/rich-format.test.ts b/tests/rich-format.test.ts new file mode 100644 index 0000000..506e5f3 --- /dev/null +++ b/tests/rich-format.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + RICH_MESSAGE_MAX_CHARS, + splitRichMarkdown, +} from '../src/rich-format.ts' + +test('splitRichMarkdown keeps small markdown as one chunk', () => { + const markdown = '# 标题\n\n- a\n- b\n\n```js\nx\n```\n' + assert.deepEqual(splitRichMarkdown(markdown), [markdown]) +}) + +test('splitRichMarkdown splits long plain text without losing content', () => { + const markdown = 'a'.repeat(RICH_MESSAGE_MAX_CHARS + 100) + const chunks = splitRichMarkdown(markdown) + assert.ok(chunks.length > 1) + assert.equal(chunks.join(''), markdown) +}) + +test('splitRichMarkdown keeps long fenced code blocks balanced', () => { + const opening = '```js\n' + const closing = '\n```' + const markdown = `${opening}${'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000)}${closing}` + const chunks = splitRichMarkdown(markdown) + assert.ok(chunks.length > 1) + for (const chunk of chunks) { + assert.ok(chunk.trimStart().startsWith('```'), 'chunk should start with a fence') + assert.ok(chunk.trimEnd().endsWith('```'), 'chunk should end with a fence') + } + assert.equal(chunks.join('').replace(/```js\n/g, '').replace(/\n```/g, ''), 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000)) +})