feat: use Telegram native Rich Message for assistant replies
Co-authored-by: MuziIsabel <MuziIsabel@users.noreply.github.com>
This commit is contained in:
@@ -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<void>
|
||||
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<void>
|
||||
private readonly maxMessageLength: number
|
||||
private renderingMode: 'rich' | 'html'
|
||||
|
||||
private readonly bindings = new Map<string, Binding>()
|
||||
private readonly pickers = new Map<string, PickerState>()
|
||||
@@ -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<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)
|
||||
for (const chunk of chunks) {
|
||||
const html = markdownToHtml(chunk)
|
||||
|
||||
@@ -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<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>
|
||||
@@ -167,6 +173,17 @@ export class TelegramClient implements TelegramClientLike {
|
||||
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> {
|
||||
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TelegramChannelConfig {
|
||||
allowAllUsers?: boolean
|
||||
maxMessageLength?: number
|
||||
pollingTimeoutSec?: number
|
||||
rendering?: string
|
||||
}
|
||||
|
||||
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
||||
@@ -20,6 +21,7 @@ export const Config: Schema<TelegramChannelConfig> = 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user