From 6c6057cf9c404a55182db2f4f7ada07689788c16 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:34:34 +0000 Subject: [PATCH] feat(channels): add DingTalk markdown normalization - Convert tables to pipe-separated plain text (DingTalk doesn't render tables) - Split long messages into chunks respecting ~3800 char limit - Handle code fences across chunk boundaries - Extract title from first markdown line for webhook payload This ensures markdown content renders correctly in DingTalk's limited renderer. Co-authored-by: Qwen-Coder --- .../channels/dingtalk/src/DingtalkAdapter.ts | 40 +++--- packages/channels/dingtalk/src/markdown.ts | 130 ++++++++++++++++++ 2 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 packages/channels/dingtalk/src/markdown.ts diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 52dc17c21..6a248c824 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -4,6 +4,7 @@ import type { RobotMessage, } from 'dingtalk-stream-sdk-nodejs'; import { ChannelBase } from '@qwen-code/channel-base'; +import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js'; import type { ChannelConfig, ChannelBaseOptions, @@ -84,25 +85,30 @@ export class DingtalkChannel extends ChannelBase { return; } - const body = { - msgtype: 'markdown', - markdown: { - title: 'Reply', - text, - }, - }; + const chunks = normalizeDingTalkMarkdown(text); + const title = extractTitle(text); - const resp = await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + for (const chunk of chunks) { + const body = { + msgtype: 'markdown', + markdown: { + title: chunks.length > 1 ? `${title} (cont.)` : title, + text: chunk, + }, + }; - if (!resp.ok) { - const detail = await resp.text().catch(() => ''); - process.stderr.write( - `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, - ); + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, + ); + } } } diff --git a/packages/channels/dingtalk/src/markdown.ts b/packages/channels/dingtalk/src/markdown.ts new file mode 100644 index 000000000..751f5d2b8 --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.ts @@ -0,0 +1,130 @@ +/** + * DingTalk markdown normalization. + * + * DingTalk's markdown renderer is a limited subset with quirks: + * - Tables don't render — convert to pipe-separated plain text + * - Max message length ~3800 chars — split into chunks + * - Code fences must be closed/reopened across chunk boundaries + */ + +const CHUNK_LIMIT = 3800; + +// --- Table conversion --- + +function isTableSeparator(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes('-')) return false; + const cells = trimmed + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); + return cells.length > 0 && cells.every((c) => /^:?-{3,}:?$/.test(c)); +} + +function isTableRow(line: string): boolean { + const trimmed = line.trim(); + return trimmed.includes('|') && !trimmed.startsWith('```'); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); +} + +function renderTable(lines: string[]): string { + const rows = lines.map(parseTableRow).filter((cells) => cells.length > 0); + return rows.map((cells) => cells.join(' | ')).join(' \n'); +} + +export function convertTables(text: string): string { + const lines = text.split('\n'); + const output: string[] = []; + let i = 0; + let inCode = false; + + while (i < lines.length) { + const line = lines[i] || ''; + if (line.trim().startsWith('```')) { + inCode = !inCode; + output.push(line); + i++; + continue; + } + + if ( + !inCode && + i + 1 < lines.length && + isTableRow(line) && + isTableSeparator(lines[i + 1] || '') + ) { + const tableLines = [line]; + i += 2; // skip header + separator + while (i < lines.length && isTableRow(lines[i] || '')) { + tableLines.push(lines[i] || ''); + i++; + } + output.push(renderTable(tableLines)); + continue; + } + + output.push(line); + i++; + } + + return output.join('\n'); +} + +// --- Chunk splitting --- + +export function splitChunks(text: string): string[] { + if (!text || text.length <= CHUNK_LIMIT) { + return [text]; + } + + const chunks: string[] = []; + let buf = ''; + const lines = text.split('\n'); + let inCode = false; + + for (const line of lines) { + const fenceCount = (line.match(/```/g) || []).length; + + if (buf.length + line.length + 1 > CHUNK_LIMIT && buf.length > 0) { + if (inCode) { + buf += '\n```'; + } + chunks.push(buf); + buf = inCode ? '```\n' : ''; + } + + buf += (buf ? '\n' : '') + line; + + if (fenceCount % 2 === 1) { + inCode = !inCode; + } + } + + if (buf) { + chunks.push(buf); + } + + return chunks; +} + +/** Extract a short title from the first line of markdown for the webhook payload. */ +export function extractTitle(text: string): string { + const firstLine = text.split('\n')[0] || ''; + const cleaned = firstLine.replace(/^[#*\s\->]+/, '').slice(0, 20); + return cleaned || 'Reply'; +} + +/** Full normalization pipeline: tables → chunks. */ +export function normalizeDingTalkMarkdown(text: string): string[] { + const converted = convertTables(text); + return splitChunks(converted); +}