feat: use Telegram native Rich Message for assistant replies
Co-authored-by: MuziIsabel <MuziIsabel@users.noreply.github.com>
This commit is contained in:
@@ -174,6 +174,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel
|
|||||||
| `allowAllUsers` | `true` 仅调试 |
|
| `allowAllUsers` | `true` 仅调试 |
|
||||||
| `maxMessageLength` | 默认 4096 |
|
| `maxMessageLength` | 默认 4096 |
|
||||||
| `pollingTimeoutSec` | 默认 30 |
|
| `pollingTimeoutSec` | 默认 30 |
|
||||||
|
| `rendering` | `rich`(默认,原生 Rich Message)或 `html`(旧 Markdown→HTML 兼容) |
|
||||||
|
|
||||||
若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。
|
若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -8,6 +8,7 @@ export interface TelegramBridgeOptions {
|
|||||||
sleep?: (ms: number) => Promise<void>;
|
sleep?: (ms: number) => Promise<void>;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
|
rendering?: 'rich' | 'html';
|
||||||
}
|
}
|
||||||
export declare class TelegramBridge {
|
export declare class TelegramBridge {
|
||||||
private readonly ctx;
|
private readonly ctx;
|
||||||
@@ -17,6 +18,7 @@ export declare class TelegramBridge {
|
|||||||
private readonly client;
|
private readonly client;
|
||||||
private readonly sleep;
|
private readonly sleep;
|
||||||
private readonly maxMessageLength;
|
private readonly maxMessageLength;
|
||||||
|
private renderingMode;
|
||||||
private readonly bindings;
|
private readonly bindings;
|
||||||
private readonly pickers;
|
private readonly pickers;
|
||||||
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
/** 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 interruptibleSleep;
|
||||||
private onSessionEvent;
|
private onSessionEvent;
|
||||||
private deliver;
|
private deliver;
|
||||||
|
private deliverHtml;
|
||||||
private redact;
|
private redact;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForW
|
|||||||
import { TelegramClient, } from './client.js';
|
import { TelegramClient, } from './client.js';
|
||||||
import { MSG, LAST_CB, parseCommand } from './commands.js';
|
import { MSG, LAST_CB, parseCommand } from './commands.js';
|
||||||
import { markdownToHtml, splitMessage } from './format.js';
|
import { markdownToHtml, splitMessage } from './format.js';
|
||||||
|
import { splitRichMarkdown } from './rich-format.js';
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js';
|
import { formatLastTurn, loadLastTurn } from './history.js';
|
||||||
import { describeAgent, displayLabel } from './label.js';
|
import { describeAgent, displayLabel } from './label.js';
|
||||||
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
|
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
|
||||||
@@ -28,6 +29,10 @@ function contentToText(content) {
|
|||||||
.map((block) => block.text)
|
.map((block) => block.text)
|
||||||
.join('');
|
.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 {
|
export class TelegramBridge {
|
||||||
ctx;
|
ctx;
|
||||||
token;
|
token;
|
||||||
@@ -36,6 +41,7 @@ export class TelegramBridge {
|
|||||||
client;
|
client;
|
||||||
sleep;
|
sleep;
|
||||||
maxMessageLength;
|
maxMessageLength;
|
||||||
|
renderingMode;
|
||||||
bindings = new Map();
|
bindings = new Map();
|
||||||
pickers = new Map();
|
pickers = new Map();
|
||||||
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
/** 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.sleep = options.sleep ?? defaultSleep;
|
||||||
this.maxMessageLength = options.maxMessageLength ?? 4096;
|
this.maxMessageLength = options.maxMessageLength ?? 4096;
|
||||||
|
this.renderingMode = options.rendering === 'html' ? 'html' : 'rich';
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
this.disposeSessionListener?.();
|
this.disposeSessionListener?.();
|
||||||
@@ -603,6 +610,29 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async deliver(chatId, markdown) {
|
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);
|
const chunks = splitMessage(markdown, this.maxMessageLength);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const html = markdownToHtml(chunk);
|
const html = markdownToHtml(chunk);
|
||||||
|
|||||||
Vendored
+6
@@ -39,6 +39,10 @@ export interface TelegramBotCommand {
|
|||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
export interface TelegramInputRichMessage {
|
||||||
|
markdown: string;
|
||||||
|
skip_entity_detection?: boolean;
|
||||||
|
}
|
||||||
export interface TelegramClientOptions {
|
export interface TelegramClientOptions {
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -48,6 +52,7 @@ export interface TelegramClientLike {
|
|||||||
getMe(): Promise<TelegramUser>;
|
getMe(): Promise<TelegramUser>;
|
||||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||||
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
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>;
|
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
||||||
@@ -63,6 +68,7 @@ export declare class TelegramClient implements TelegramClientLike {
|
|||||||
getMe(): Promise<TelegramUser>;
|
getMe(): Promise<TelegramUser>;
|
||||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||||
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
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>;
|
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export class TelegramClient {
|
|||||||
}
|
}
|
||||||
return this.call('sendMessage', body);
|
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) {
|
async sendChatAction(chatId, action) {
|
||||||
return this.call('sendChatAction', { chat_id: chatId, action });
|
return this.call('sendChatAction', { chat_id: chatId, action });
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -9,6 +9,7 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean;
|
allowAllUsers?: boolean;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
|
rendering?: string;
|
||||||
}
|
}
|
||||||
export declare const Config: Schema<TelegramChannelConfig>;
|
export declare const Config: Schema<TelegramChannelConfig>;
|
||||||
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const Config = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
|
rendering: Schema.string().default('rich'),
|
||||||
});
|
});
|
||||||
function resolveAllowedUserIds(config) {
|
function resolveAllowedUserIds(config) {
|
||||||
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
||||||
@@ -36,6 +37,7 @@ export function apply(ctx, config) {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
|
rendering: config.rendering === 'html' ? 'html' : 'rich',
|
||||||
});
|
});
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start();
|
bridge.start();
|
||||||
|
|||||||
Vendored
+3
@@ -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[];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from './client.js'
|
} from './client.js'
|
||||||
import { MSG, LAST_CB, parseCommand } from './commands.js'
|
import { MSG, LAST_CB, parseCommand } from './commands.js'
|
||||||
import { markdownToHtml, splitMessage } from './format.js'
|
import { markdownToHtml, splitMessage } from './format.js'
|
||||||
|
import { splitRichMarkdown } from './rich-format.js'
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js'
|
import { formatLastTurn, loadLastTurn } from './history.js'
|
||||||
import { describeAgent, displayLabel } from './label.js'
|
import { describeAgent, displayLabel } from './label.js'
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,7 @@ export interface TelegramBridgeOptions {
|
|||||||
sleep?: (ms: number) => Promise<void>
|
sleep?: (ms: number) => Promise<void>
|
||||||
maxMessageLength?: number
|
maxMessageLength?: number
|
||||||
pollingTimeoutSec?: number
|
pollingTimeoutSec?: number
|
||||||
|
rendering?: 'rich' | 'html'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** chatId → bound live session id (string form of SessionId) */
|
/** chatId → bound live session id (string form of SessionId) */
|
||||||
@@ -84,6 +86,11 @@ function contentToText(content: readonly ContentBlock[]): string {
|
|||||||
.join('')
|
.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 {
|
export class TelegramBridge {
|
||||||
private readonly ctx: Context
|
private readonly ctx: Context
|
||||||
private readonly token: string
|
private readonly token: string
|
||||||
@@ -92,6 +99,7 @@ export class TelegramBridge {
|
|||||||
private readonly client: TelegramClientLike
|
private readonly client: TelegramClientLike
|
||||||
private readonly sleep: (ms: number) => Promise<void>
|
private readonly sleep: (ms: number) => Promise<void>
|
||||||
private readonly maxMessageLength: number
|
private readonly maxMessageLength: number
|
||||||
|
private renderingMode: 'rich' | 'html'
|
||||||
|
|
||||||
private readonly bindings = new Map<string, Binding>()
|
private readonly bindings = new Map<string, Binding>()
|
||||||
private readonly pickers = new Map<string, PickerState>()
|
private readonly pickers = new Map<string, PickerState>()
|
||||||
@@ -113,6 +121,7 @@ export class TelegramBridge {
|
|||||||
})
|
})
|
||||||
this.sleep = options.sleep ?? defaultSleep
|
this.sleep = options.sleep ?? defaultSleep
|
||||||
this.maxMessageLength = options.maxMessageLength ?? 4096
|
this.maxMessageLength = options.maxMessageLength ?? 4096
|
||||||
|
this.renderingMode = options.rendering === 'html' ? 'html' : 'rich'
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -680,6 +689,32 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deliver(chatId: number, markdown: string): Promise<void> {
|
private async deliver(chatId: number, markdown: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
const chunks = splitMessage(markdown, this.maxMessageLength)
|
const chunks = splitMessage(markdown, this.maxMessageLength)
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const html = markdownToHtml(chunk)
|
const html = markdownToHtml(chunk)
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export interface TelegramBotCommand {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelegramInputRichMessage {
|
||||||
|
markdown: string
|
||||||
|
skip_entity_detection?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramClientOptions {
|
export interface TelegramClientOptions {
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
@@ -64,6 +69,7 @@ export interface TelegramClientLike {
|
|||||||
parseMode?: string,
|
parseMode?: string,
|
||||||
replyMarkup?: InlineKeyboardMarkup,
|
replyMarkup?: InlineKeyboardMarkup,
|
||||||
): Promise<TelegramMessage>
|
): Promise<TelegramMessage>
|
||||||
|
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>
|
||||||
sendChatAction(chatId: number, action: string): Promise<boolean>
|
sendChatAction(chatId: number, action: string): Promise<boolean>
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
|
||||||
@@ -167,6 +173,17 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
return this.call<TelegramMessage>('sendMessage', body)
|
return this.call<TelegramMessage>('sendMessage', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
chat_id: chatId,
|
||||||
|
rich_message: {
|
||||||
|
markdown,
|
||||||
|
skip_entity_detection: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return this.call<TelegramMessage>('sendRichMessage', body)
|
||||||
|
}
|
||||||
|
|
||||||
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
||||||
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean
|
allowAllUsers?: boolean
|
||||||
maxMessageLength?: number
|
maxMessageLength?: number
|
||||||
pollingTimeoutSec?: number
|
pollingTimeoutSec?: number
|
||||||
|
rendering?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
||||||
@@ -20,6 +21,7 @@ export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
|
rendering: Schema.string().default('rich'),
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
||||||
@@ -51,6 +53,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
|
rendering: config.rendering === 'html' ? 'html' : 'rich',
|
||||||
})
|
})
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start()
|
bridge.start()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -51,3 +51,24 @@ test('sendMessage can include reply_markup', async () => {
|
|||||||
inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]],
|
inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('sendRichMessage posts native rich markdown body', async () => {
|
||||||
|
const calls: Array<{ url: string; body: Record<string, unknown> }> = []
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user