feat: use Telegram native Rich Message for assistant replies

Co-authored-by: MuziIsabel <MuziIsabel@users.noreply.github.com>
This commit is contained in:
MuziIsabel
2026-08-18 18:27:32 +08:00
committed by GitHub
co-authored by MuziIsabel
parent 819010de1c
commit 93f3bec975
15 changed files with 675 additions and 0 deletions
+3
View File
@@ -8,6 +8,7 @@ export interface TelegramBridgeOptions {
sleep?: (ms: number) => Promise<void>;
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;
}
+30
View File
@@ -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);
+6
View File
@@ -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<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
sendChatAction(chatId: number, action: string): Promise<boolean>;
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
@@ -63,6 +68,7 @@ export declare class TelegramClient implements TelegramClientLike {
getMe(): Promise<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
sendChatAction(chatId: number, action: string): Promise<boolean>;
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
+10
View File
@@ -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 });
}
+1
View File
@@ -9,6 +9,7 @@ export interface TelegramChannelConfig {
allowAllUsers?: boolean;
maxMessageLength?: number;
pollingTimeoutSec?: number;
rendering?: string;
}
export declare const Config: Schema<TelegramChannelConfig>;
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
+2
View File
@@ -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();
+3
View File
@@ -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[];
+224
View File
@@ -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;
}