From b77e7306577575c6cc50e4c853592988baf149fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 17:56:50 +0000 Subject: [PATCH] fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) --- CHANGELOG.md | 1 + docs/concepts/markdown-formatting.md | 26 +- extensions/bluebubbles/src/config-schema.ts | 2 + extensions/bluebubbles/src/monitor.test.ts | 2 + extensions/bluebubbles/src/monitor.ts | 18 +- extensions/matrix/src/config-schema.ts | 2 + .../matrix/src/matrix/monitor/handler.ts | 7 + .../matrix/src/matrix/monitor/replies.ts | 19 +- extensions/matrix/src/matrix/send.test.ts | 2 + extensions/matrix/src/matrix/send.ts | 11 +- extensions/matrix/src/matrix/send/types.ts | 1 + extensions/mattermost/src/config-schema.ts | 2 + .../mattermost/src/mattermost/monitor.ts | 7 +- extensions/mattermost/src/mattermost/send.ts | 11 +- extensions/msteams/src/messenger.test.ts | 10 +- extensions/msteams/src/messenger.ts | 13 +- extensions/msteams/src/reply-dispatcher.ts | 5 + extensions/msteams/src/send.ts | 23 +- .../nextcloud-talk/src/config-schema.ts | 2 + extensions/nextcloud-talk/src/send.ts | 12 +- extensions/nostr/src/channel.ts | 9 +- extensions/nostr/src/config-schema.ts | 5 +- extensions/zalo/src/config-schema.ts | 2 + extensions/zalo/src/monitor.ts | 18 +- extensions/zalouser/src/config-schema.ts | 2 + extensions/zalouser/src/monitor.ts | 16 +- src/config/markdown-tables.ts | 60 +++ src/config/types.base.ts | 7 + src/config/types.discord.ts | 3 + src/config/types.imessage.ts | 9 +- src/config/types.msteams.ts | 9 +- src/config/types.signal.ts | 9 +- src/config/types.slack.ts | 3 + src/config/types.telegram.ts | 3 + src/config/types.whatsapp.ts | 11 +- src/config/zod-schema.core.ts | 9 + src/config/zod-schema.providers-core.ts | 8 + src/config/zod-schema.providers-whatsapp.ts | 3 + .../monitor/message-handler.process.ts | 7 + src/discord/monitor/reply-delivery.ts | 7 +- src/discord/send.outbound.ts | 12 +- src/imessage/monitor/deliver.ts | 12 +- src/imessage/send.ts | 10 + src/infra/outbound/deliver.ts | 14 +- src/markdown/ir.table-bullets.test.ts | 56 ++- src/markdown/ir.ts | 360 ++++++++++++------ src/markdown/tables.ts | 34 ++ src/plugin-sdk/index.ts | 4 + src/plugins/runtime/index.ts | 4 + src/plugins/runtime/types.ts | 5 + src/signal/format.ts | 18 +- src/signal/send.ts | 8 +- src/slack/format.ts | 18 +- src/slack/monitor/replies.ts | 6 +- src/slack/monitor/slash.ts | 11 + src/slack/send.ts | 8 +- src/telegram/bot-message-dispatch.ts | 7 + src/telegram/bot-native-commands.ts | 7 + src/telegram/bot/delivery.ts | 10 +- src/telegram/format.ts | 11 +- src/telegram/send.ts | 8 +- src/web/auto-reply/deliver-reply.ts | 7 +- src/web/auto-reply/monitor/process-message.ts | 7 + src/web/outbound.ts | 10 + 64 files changed, 837 insertions(+), 186 deletions(-) create mode 100644 src/config/markdown-tables.ts create mode 100644 src/markdown/tables.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 859c74a48..fb89dd89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. +- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md index da2c1b268..91799a3e9 100644 --- a/docs/concepts/markdown-formatting.md +++ b/docs/concepts/markdown-formatting.md @@ -25,6 +25,7 @@ stay consistent across channels. 1. **Parse Markdown -> IR** - IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. - Offsets are UTF-16 code units so Signal style ranges align with its API. + - Tables are parsed only when a channel opts into table conversion. 2. **Chunk IR (format-first)** - Chunking happens on the IR text before rendering. - Inline formatting does not split across chunks; spans are sliced per chunk. @@ -59,7 +60,30 @@ IR (schematic): - Slack, Telegram, and Signal outbound adapters render from the IR. - Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or - their own formatting rules. + their own formatting rules, with Markdown table conversion applied before + chunking when enabled. + +## Table handling + +Markdown tables are not consistently supported across chat clients. Use +`markdown.tables` to control conversion per channel (and per account). + +- `code`: render tables as code blocks (default for most channels). +- `bullets`: convert each row into bullet points (default for Signal + WhatsApp). +- `off`: disable table parsing and conversion; raw table text passes through. + +Config keys: + +```yaml +channels: + discord: + markdown: + tables: code + accounts: + work: + markdown: + tables: off +``` ## Chunking rules diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 84b389142..9e2f6e50f 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({ const bluebubblesAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, serverUrl: z.string().optional(), password: z.string().optional(), webhookPath: z.string().optional(), diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 96e85e84b..fa40e82a7 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime { chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"], + convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"], }, reply: { dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ab503882d..81a921ca9 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1662,9 +1662,15 @@ async function processMessage( ? [payload.mediaUrl] : []; if (mediaList.length > 0) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; const result = await sendBlueBubblesMedia({ cfg: config, @@ -1686,8 +1692,14 @@ async function processMessage( account.config.textChunkLimit && account.config.textChunkLimit > 0 ? account.config.textChunkLimit : DEFAULT_TEXT_LIMIT; - const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); - if (!chunks.length && payload.text) chunks.push(payload.text); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const chunks = core.channel.text.chunkMarkdownText(text, textLimit); + if (!chunks.length && text) chunks.push(text); if (!chunks.length) return; for (const chunk of chunks) { const result = await sendMessageBlueBubbles(outboundTarget, chunk, { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 3cb396883..2d035dc43 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -35,6 +36,7 @@ const matrixRoomSchema = z export const MatrixConfigSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, homeserver: z.string().optional(), userId: z.string().optional(), accessToken: z.string().optional(), diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 62a7a2c26..49deabbf8 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } let didSendReply = false; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: route.accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + accountId: route.accountId, + tableMode, }); didSendReply = true; }, diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 7a9cc06aa..d2a6e34da 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,6 +1,6 @@ import type { MatrixClient } from "matrix-bot-sdk"; -import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; import { getMatrixRuntime } from "../../runtime.js"; @@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: { textLimit: number; replyToMode: "off" | "first" | "all"; threadId?: string; + accountId?: string; + tableMode?: MarkdownTableMode; }): Promise { const core = getMatrixRuntime(); + const tableMode = + params.tableMode ?? + core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "matrix", + accountId: params.accountId, + }); const logVerbose = (message: string) => { if (core.logging.shouldLogVerbose()) { params.runtime.log?.(message); @@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: { } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl @@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); if (mediaList.length === 0) { - for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) { + for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed) continue; await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; @@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? (reply.text ?? "") : ""; + const caption = first ? text : ""; await sendMessageMatrix(params.roomId, caption, { client: params.client, mediaUrl, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 5520d126e..2f0053ecf 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -43,6 +43,8 @@ const runtimeStub = { text: { resolveTextChunkLimit: () => 4000, chunkMarkdownText: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 634871123..79d20471c 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -50,9 +50,18 @@ export async function sendMessageMatrix( try { const roomId = await resolveMatrixRoomId(client, to); const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); + const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 51b1b1024..eb59f8a62 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -87,6 +87,7 @@ export type MatrixSendResult = { export type MatrixSendOpts = { client?: import("matrix-bot-sdk").MatrixClient; mediaUrl?: string; + accountId?: string; replyToId?: string; threadId?: string | number | null; timeoutMs?: number; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 618747995..40ae8a31a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,6 +4,7 @@ import { BlockStreamingCoalesceSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; @@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), botToken: z.string().optional(), diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 7e5079ecb..cce05f0cb 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { fallbackLimit: account.textChunkLimit ?? 4000, }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), @@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); if (mediaUrls.length === 0) { const chunks = core.channel.text.chunkMarkdownText(text, textLimit); for (const chunk of chunks.length > 0 ? chunks : [text]) { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index c2a2a251c..cd205340d 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -181,6 +181,15 @@ export async function sendMessageMattermost( } } + if (message) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + message = core.channel.text.convertMarkdownTables(message, tableMode); + } + if (!message && (!fileIds || fileIds.length === 0)) { if (uploadError) { throw new Error(`Mattermost media upload failed: ${uploadError.message}`); @@ -205,4 +214,4 @@ export async function sendMessageMattermost( messageId: post.id ?? "unknown", channelId, }; -} \ No newline at end of file +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 04d1f55e1..9fbd628c5 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -21,6 +21,8 @@ const runtimeStub = { } return chunks; }, + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; @@ -34,6 +36,7 @@ describe("msteams messenger", () => { it("filters silent replies", () => { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { textChunkLimit: 4000, + tableMode: "code", }); expect(messages).toEqual([]); }); @@ -41,7 +44,7 @@ describe("msteams messenger", () => { it("filters silent reply prefixes", () => { const messages = renderReplyPayloadsToMessages( [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([]); }); @@ -49,7 +52,7 @@ describe("msteams messenger", () => { it("splits media into separate messages by default", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); }); @@ -57,7 +60,7 @@ describe("msteams messenger", () => { it("supports inline media mode", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000, mediaMode: "inline" }, + { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); }); @@ -66,6 +69,7 @@ describe("msteams messenger", () => { const long = "hello ".repeat(200); const messages = renderReplyPayloadsToMessages([{ text: long }], { textChunkLimit: 50, + tableMode: "code", }); expect(messages.length).toBeGreaterThan(1); }); diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index d6a0b9963..a5eb99b73 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -1,6 +1,7 @@ import { isSilentReplyText, loadWebMedia, + type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, SILENT_REPLY_TOKEN, @@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = { textChunkLimit: number; chunkText?: boolean; mediaMode?: "split" | "inline"; + tableMode?: MarkdownTableMode; }; /** @@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages( const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkText = options.chunkText !== false; const mediaMode = options.mediaMode ?? "split"; + const tableMode = + options.tableMode ?? + getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg: getMSTeamsRuntime().config.loadConfig(), + channel: "msteams", + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode, + ); if (!text && mediaList.length === 0) continue; diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index d50661264..f711c8240 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: { ).responsePrefix, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", + }); const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, chunkText: true, mediaMode: "split", + tableMode, }); const mediaMaxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 83d0cf149..82a4114ef 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -16,6 +16,7 @@ import { import { extractFilename, extractMessageId } from "./media-helpers.js"; import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; import { buildMSTeamsPollCard } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; export type SendMSTeamsMessageParams = { @@ -93,13 +94,21 @@ export async function sendMessageMSTeams( params: SendMSTeamsMessageParams, ): Promise { const { cfg, to, text, mediaUrl } = params; + const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "msteams", + }); + const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables( + text ?? "", + tableMode, + ); const ctx = await resolveMSTeamsSendContext({ cfg, to }); const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug("sending proactive message", { conversationId, conversationType, - textLength: text.length, + textLength: messageText.length, hasMedia: Boolean(mediaUrl), }); @@ -134,7 +143,7 @@ export async function sendMessageMSTeams( const { activity, uploadId } = prepareFileConsentActivity({ media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, conversationId, - description: text || undefined, + description: messageText || undefined, }); log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); @@ -172,14 +181,14 @@ export async function sendMessageMSTeams( const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } if (isImage && !sharePointSiteId) { // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } // Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive @@ -223,7 +232,7 @@ export async function sendMessageMSTeams( const fileCardAttachment = buildTeamsFileInfoCard(driveItem); const activity = { type: "message", - text: text || undefined, + text: messageText || undefined, attachments: [fileCardAttachment], }; @@ -264,7 +273,7 @@ export async function sendMessageMSTeams( const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; const activity = { type: "message", - text: text ? `${text}\n\n${fileLink}` : fileLink, + text: messageText ? `${messageText}\n\n${fileLink}` : fileLink, }; const baseRef = buildConversationReference(ref); @@ -290,7 +299,7 @@ export async function sendMessageMSTeams( } // No media: send text only - return sendTextWithMedia(ctx, text, undefined); + return sendTextWithMedia(ctx, messageText, undefined); } /** diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index c442f6b59..085319d1c 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -3,6 +3,7 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; import { z } from "zod"; @@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, baseUrl: z.string().optional(), botSecret: z.string().optional(), botSecretFile: z.string().optional(), diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index cf55f5509..1dd8f5094 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk( throw new Error("Message must be non-empty for Nextcloud Talk sends"); } + const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "nextcloud-talk", + accountId: account.accountId, + }); + const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables( + text.trim(), + tableMode, + ); + const body: Record = { - message: text.trim(), + message, }; if (opts.replyTo) { body.replyTo = opts.replyTo; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 30f2f7dfc..e6df0872c 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 4000, sendText: async ({ to, text, accountId }) => { + const core = getNostrRuntime(); const aid = accountId ?? DEFAULT_ACCOUNT_ID; const bus = activeBuses.get(aid); if (!bus) { throw new Error(`Nostr bus not running for account ${aid}`); } + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "nostr", + accountId: aid, + }); + const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); - await bus.sendDm(normalizedTo, text); + await bus.sendDm(normalizedTo, message); return { channel: "nostr", to: normalizedTo }; }, }, diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index bb01a068d..08ac773b0 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,5 +1,5 @@ +import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; -import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({ /** Whether this channel is enabled */ enabled: z.boolean().optional(), + /** Markdown formatting overrides (tables). */ + markdown: MarkdownConfigSchema, + /** Private key in hex or nsec bech32 format */ privateKey: z.string().optional(), diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 3ab955848..25e22bd3b 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]); const zaloAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, botToken: z.string().optional(), tokenFile: z.string().optional(), webhookUrl: z.string().optional(), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index cb68388cf..939dcdbde 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: { runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalo", + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, @@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: { core, statusSink, fetcher, + tableMode, }); }, onError: (err, info) => { @@ -608,8 +615,11 @@ async function deliverZaloReply(params: { core: ZaloCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; fetcher?: ZaloFetch; + tableMode?: MarkdownTableMode; }): Promise { const { payload, token, chatId, runtime, core, statusSink, fetcher } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -620,7 +630,7 @@ async function deliverZaloReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); @@ -632,8 +642,8 @@ async function deliverZaloReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT); for (const chunk of chunks) { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index ca36c1c72..bf80d28c0 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -10,6 +11,7 @@ const groupConfigSchema = z.object({ const zalouserAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, profile: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index b3ab31dd3..4015fcc8d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; -import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk"; import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk"; import { sendMessageZalouser } from "./send.js"; import type { @@ -332,6 +332,11 @@ async function processMessage( runtime, core, statusSink, + tableMode: core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalouser", + accountId: account.accountId, + }), }); }, onError: (err, info) => { @@ -351,8 +356,11 @@ async function deliverZalouserReply(params: { runtime: RuntimeEnv; core: ZalouserCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + tableMode?: MarkdownTableMode; }): Promise { const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -363,7 +371,7 @@ async function deliverZalouserReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { logVerbose(core, runtime, `Sending media to ${chatId}`); @@ -380,8 +388,8 @@ async function deliverZalouserReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); for (const chunk of chunks) { try { diff --git a/src/config/markdown-tables.ts b/src/config/markdown-tables.ts new file mode 100644 index 000000000..387ad6cab --- /dev/null +++ b/src/config/markdown-tables.ts @@ -0,0 +1,60 @@ +import { normalizeChannelId } from "../channels/plugins/index.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import type { ClawdbotConfig } from "./config.js"; +import type { MarkdownTableMode } from "./types.base.js"; + +type MarkdownConfigEntry = { + markdown?: { + tables?: MarkdownTableMode; + }; +}; + +type MarkdownConfigSection = MarkdownConfigEntry & { + accounts?: Record; +}; + +const DEFAULT_TABLE_MODES = new Map([ + ["signal", "bullets"], + ["whatsapp", "bullets"], +]); + +const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode => + value === "off" || value === "bullets" || value === "code"; + +function resolveMarkdownModeFromSection( + section: MarkdownConfigSection | undefined, + accountId?: string | null, +): MarkdownTableMode | undefined { + if (!section) return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const accounts = section.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + const directMode = direct?.markdown?.tables; + if (isMarkdownTableMode(directMode)) return directMode; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + const matchMode = match?.markdown?.tables; + if (isMarkdownTableMode(matchMode)) return matchMode; + } + const sectionMode = section.markdown?.tables; + return isMarkdownTableMode(sectionMode) ? sectionMode : undefined; +} + +export function resolveMarkdownTableMode(params: { + cfg?: Partial; + channel?: string | null; + accountId?: string | null; +}): MarkdownTableMode { + const channel = normalizeChannelId(params.channel); + const defaultMode = channel ? (DEFAULT_TABLE_MODES.get(channel) ?? "code") : "code"; + if (!channel || !params.cfg) return defaultMode; + const channelsConfig = params.cfg.channels as Record | undefined; + const section = (channelsConfig?.[channel] ?? + (params.cfg as Record | undefined)?.[channel]) as + | MarkdownConfigSection + | undefined; + return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode; +} diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 2fe689f95..a84736571 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = { breakPreference?: "paragraph" | "newline" | "sentence"; }; +export type MarkdownTableMode = "off" | "bullets" | "code"; + +export type MarkdownConfig = { + /** Table rendering mode (off|bullets|code). */ + tables?: MarkdownTableMode; +}; + export type HumanDelayConfig = { /** Delay style for block replies (off|natural|custom). */ mode?: "off" | "natural" | "custom"; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c8f0a38b3..cdedcb0d7 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -70,6 +71,8 @@ export type DiscordAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Discord (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 37e4c5453..c166fee54 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type IMessageAccountConfig = { @@ -6,6 +11,8 @@ export type IMessageAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this iMessage account. Default: true. */ diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index f18dccb14..170c64e47 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type MSTeamsWebhookConfig = { @@ -34,6 +39,8 @@ export type MSTeamsConfig = { enabled?: boolean; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Azure Bot App ID (from Azure Bot registration). */ diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index c71d97169..f46fb0f8f 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; @@ -8,6 +13,8 @@ export type SignalAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Signal account. Default: true. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index f0e9e1f21..e2ca63b3c 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -80,6 +81,8 @@ export type SlackAccountConfig = { webhookPath?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Slack (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 3533d6d4f..02a822c13 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -3,6 +3,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -35,6 +36,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: TelegramCapabilitiesConfig; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Custom commands to register in Telegram's command menu (merged with native). */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 28ed34c56..90b5497d4 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type WhatsAppActionConfig = { @@ -12,6 +17,8 @@ export type WhatsAppConfig = { accounts?: Record; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Send read receipts for incoming messages (default true). */ @@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this WhatsApp account provider. Default: true. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 01427ab86..7bdf86bdf 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z }) .strict(); +export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]); + +export const MarkdownConfigSchema = z + .object({ + tables: MarkdownTableModeSchema.optional(), + }) + .strict() + .optional(); + export const HumanDelaySchema = z .object({ mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f687253c..12f6cbb3d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -7,6 +7,7 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, + MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, ReplyToModeSchema, @@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, customCommands: z.array(TelegramCustomCommandSchema).optional(), @@ -193,6 +195,7 @@ export const DiscordAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -296,6 +299,7 @@ export const SlackAccountSchema = z signingSecret: z.string().optional(), webhookPath: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), account: z.string().optional(), @@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), @@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), serverUrl: z.string().optional(), @@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z .object({ enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), appId: z.string().optional(), appPassword: z.string().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 6de67790d..de6cda2f8 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -5,12 +5,14 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, } from "./zod-schema.core.js"; export const WhatsAppAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), @@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z .object({ accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 4838c9d44..ad1e4baea 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -27,6 +27,7 @@ import { resolveStorePath, updateLastRoute, } from "../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; @@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, textLimit, maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + tableMode, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index e7713af1e..f54efb1b9 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -1,6 +1,8 @@ import type { RequestClient } from "@buape/carbon"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import { chunkDiscordText } from "../chunk.js"; import { sendMessageDiscord } from "../send.js"; @@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: { textLimit: number; maxLinesPerMessage?: number; replyToId?: string; + tableMode?: MarkdownTableMode; }) { const chunkLimit = Math.min(params.textLimit, 2000); for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const tableMode = params.tableMode ?? "code"; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; const replyTo = params.replyToId?.trim() || undefined; diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 51d5742e0..3c83f7b94 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -1,7 +1,9 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import type { RetryConfig } from "../infra/retry.js"; import type { PollInput } from "../polls.js"; import { resolveDiscordAccount } from "./accounts.js"; @@ -38,6 +40,12 @@ export async function sendMessageDiscord( cfg, accountId: opts.accountId, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId: accountInfo.accountId, + }); + const textWithTables = convertMarkdownTables(text ?? "", tableMode); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient, request); @@ -47,7 +55,7 @@ export async function sendMessageDiscord( result = await sendDiscordMedia( rest, channelId, - text, + textWithTables, opts.mediaUrl, opts.replyTo, request, @@ -58,7 +66,7 @@ export async function sendMessageDiscord( result = await sendDiscordText( rest, channelId, - text, + textWithTables, opts.replyTo, request, accountInfo.config.maxLinesPerMessage, diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 779cbd3e5..aa3c6dbb1 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -1,4 +1,7 @@ import { chunkText } from "../../auto-reply/chunk.js"; +import { loadConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; @@ -14,9 +17,16 @@ export async function deliverReplies(params: { textLimit: number; }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params; + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId, + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { for (const chunk of chunkText(text, textLimit)) { diff --git a/src/imessage/send.ts b/src/imessage/send.ts index 32e963bc8..30972ef09 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -1,7 +1,9 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { resolveIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; @@ -88,6 +90,14 @@ export async function sendMessageIMessage( if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } + if (message.trim()) { + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId: account.accountId, + }); + message = convertMarkdownTables(message, tableMode); + } const params: Record = { text: message, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 21fffe807..73f5550e0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import type { sendMessageDiscord } from "../../discord/send.js"; import type { sendMessageIMessage } from "../../imessage/send.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; @@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: { }) : undefined; const isSignalChannel = channel === "signal"; + const signalTableMode = isSignalChannel + ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) + : "code"; const signalMaxBytes = isSignalChannel ? resolveChannelMediaMaxBytes({ cfg, @@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: { throwIfAborted(abortSignal); let signalChunks = textLimit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY) - : markdownToSignalTextChunks(text, textLimit); + ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + }) + : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); if (signalChunks.length === 0 && text) { signalChunks = [{ text, styles: [] }]; } @@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: { const sendSignalMedia = async (caption: string, mediaUrl: string) => { throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? { + const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + })[0] ?? { text: caption, styles: [], }; diff --git a/src/markdown/ir.table-bullets.test.ts b/src/markdown/ir.table-bullets.test.ts index 841c922fe..358cb7eac 100644 --- a/src/markdown/ir.table-bullets.test.ts +++ b/src/markdown/ir.table-bullets.test.ts @@ -11,7 +11,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should contain bullet points with header:value format expect(ir.text).toContain("• Value: 1"); expect(ir.text).toContain("• Value: 2"); @@ -29,7 +29,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // First column becomes row label expect(ir.text).toContain("Speed"); expect(ir.text).toContain("Scale"); @@ -40,22 +40,20 @@ describe("markdownToIR tableMode bullets", () => { expect(ir.text).toContain("• Postgres: Large"); }); - it("preserves flat mode as default", () => { + it("leaves table syntax untouched by default", () => { const md = ` | A | B | |---|---| | 1 | 2 | `.trim(); - const ir = markdownToIR(md); // default is flat - - // Flat mode uses tabs - expect(ir.text).toContain("A"); - expect(ir.text).toContain("B"); - expect(ir.text).toContain("1"); - expect(ir.text).toContain("2"); - // Should not have bullet formatting + const ir = markdownToIR(md); + + // No table conversion by default + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); expect(ir.text).not.toContain("•"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(false); }); it("handles empty cells gracefully", () => { @@ -67,7 +65,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should handle empty cell without crashing expect(ir.text).toContain("B"); expect(ir.text).toContain("• Value: 2"); @@ -81,11 +79,41 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should have bold style for row label const hasRowLabelBold = ir.styles.some( - (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1" + (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1", ); expect(hasRowLabelBold).toBe(true); }); + + it("renders tables as code blocks in code mode", () => { + const md = ` +| A | B | +|---|---| +| 1 | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "code" }); + + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(true); + }); + + it("preserves inline styles and links in bullets mode", () => { + const md = ` +| Name | Value | +|------|-------| +| _Row_ | [Link](https://example.com) | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + const hasItalic = ir.styles.some( + (s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row", + ); + expect(hasItalic).toBe(true); + expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true); + }); }); diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 5351fa32c..186abeda0 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import { chunkText } from "../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type ListState = { type: "bullet" | "ordered"; @@ -12,24 +13,8 @@ type LinkState = { labelStart: number; }; -type TableCell = { - content: string; - isHeader: boolean; -}; - -type TableRow = TableCell[]; - -type TableState = { - headers: string[]; - rows: TableRow[]; - currentRow: TableCell[]; - currentCell: string; - inHeader: boolean; -}; - type RenderEnv = { listStack: ListState[]; - linkStack: LinkState[]; }; type MarkdownToken = { @@ -65,19 +50,36 @@ type OpenStyle = { start: number; }; -export type TableRenderMode = "flat" | "bullets"; - -type RenderState = { +type RenderTarget = { text: string; styles: MarkdownStyleSpan[]; openStyles: OpenStyle[]; links: MarkdownLinkSpan[]; + linkStack: LinkState[]; +}; + +type TableCell = { + text: string; + styles: MarkdownStyleSpan[]; + links: MarkdownLinkSpan[]; +}; + +type TableState = { + headers: TableCell[]; + rows: TableCell[][]; + currentRow: TableCell[]; + currentCell: RenderTarget | null; + inHeader: boolean; +}; + +type RenderState = RenderTarget & { env: RenderEnv; headingStyle: "none" | "bold"; blockquotePrefix: string; enableSpoilers: boolean; - tableMode: TableRenderMode; + tableMode: MarkdownTableMode; table: TableState | null; + hasTables: boolean; }; export type MarkdownParseOptions = { @@ -86,8 +88,8 @@ export type MarkdownParseOptions = { headingStyle?: "none" | "bold"; blockquotePrefix?: string; autolink?: boolean; - /** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */ - tableMode?: TableRenderMode; + /** How to render tables (off|bullets|code). Default: off. */ + tableMode?: MarkdownTableMode; }; function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { @@ -98,7 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { typographer: false, }); md.enable("strikethrough"); - md.enable("table"); + if (options.tableMode && options.tableMode !== "off") { + md.enable("table"); + } else { + md.disable("table"); + } if (options.autolink === false) { md.disable("autolink"); } @@ -166,28 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { return result; } +function initRenderTarget(): RenderTarget { + return { + text: "", + styles: [], + openStyles: [], + links: [], + linkStack: [], + }; +} + +function resolveRenderTarget(state: RenderState): RenderTarget { + return state.table?.currentCell ?? state; +} + function appendText(state: RenderState, value: string) { if (!value) return; - // If we're inside a table cell in bullets mode, collect into cell buffer - if (state.table && state.tableMode === "bullets") { - state.table.currentCell += value; - return; - } - state.text += value; + const target = resolveRenderTarget(state); + target.text += value; } function openStyle(state: RenderState, style: MarkdownStyle) { - state.openStyles.push({ style, start: state.text.length }); + const target = resolveRenderTarget(state); + target.openStyles.push({ style, start: target.text.length }); } function closeStyle(state: RenderState, style: MarkdownStyle) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - if (state.openStyles[i]?.style === style) { - const start = state.openStyles[i].start; - state.openStyles.splice(i, 1); - const end = state.text.length; + const target = resolveRenderTarget(state); + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + if (target.openStyles[i]?.style === style) { + const start = target.openStyles[i].start; + target.openStyles.splice(i, 1); + const end = target.text.length; if (end > start) { - state.styles.push({ start, end, style }); + target.styles.push({ start, end, style }); } return; } @@ -212,39 +230,37 @@ function appendListPrefix(state: RenderState) { function renderInlineCode(state: RenderState, content: string) { if (!content) return; - // In bullets mode inside table, just add text without styling - if (state.table && state.tableMode === "bullets") { - state.table.currentCell += content; - return; - } - const start = state.text.length; - state.text += content; - state.styles.push({ start, end: start + content.length, style: "code" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += content; + target.styles.push({ start, end: start + content.length, style: "code" }); } function renderCodeBlock(state: RenderState, content: string) { let code = content ?? ""; if (!code.endsWith("\n")) code = `${code}\n`; - const start = state.text.length; - state.text += code; - state.styles.push({ start, end: start + code.length, style: "code_block" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += code; + target.styles.push({ start, end: start + code.length, style: "code_block" }); if (state.env.listStack.length === 0) { - state.text += "\n"; + target.text += "\n"; } } function handleLinkClose(state: RenderState) { - const link = state.env.linkStack.pop(); + const target = resolveRenderTarget(state); + const link = target.linkStack.pop(); if (!link?.href) return; const href = link.href.trim(); if (!href) return; const start = link.labelStart; - const end = state.text.length; + const end = target.text.length; if (end <= start) { - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); return; } - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); } function initTableState(): TableState { @@ -252,14 +268,72 @@ function initTableState(): TableState { headers: [], rows: [], currentRow: [], - currentCell: "", + currentCell: null, inHeader: false, }; } +function finishTableCell(cell: RenderTarget): TableCell { + closeRemainingStyles(cell); + return { + text: cell.text, + styles: cell.styles, + links: cell.links, + }; +} + +function trimCell(cell: TableCell): TableCell { + const text = cell.text; + let start = 0; + let end = text.length; + while (start < end && /\s/.test(text[start] ?? "")) start += 1; + while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1; + if (start === 0 && end === text.length) return cell; + const trimmedText = text.slice(start, end); + const trimmedLength = trimmedText.length; + const trimmedStyles: MarkdownStyleSpan[] = []; + for (const span of cell.styles) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style }); + } + } + const trimmedLinks: MarkdownLinkSpan[] = []; + for (const span of cell.links) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href }); + } + } + return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks }; +} + +function appendCell(state: RenderState, cell: TableCell) { + if (!cell.text) return; + const start = state.text.length; + state.text += cell.text; + for (const span of cell.styles) { + state.styles.push({ + start: start + span.start, + end: start + span.end, + style: span.style, + }); + } + for (const link of cell.links) { + state.links.push({ + start: start + link.start, + end: start + link.end, + href: link.href, + }); + } +} + function renderTableAsBullets(state: RenderState) { if (!state.table) return; - const { headers, rows } = state.table; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); // If no headers or rows, skip if (headers.length === 0 && rows.length === 0) return; @@ -273,22 +347,31 @@ function renderTableAsBullets(state: RenderState) { for (const row of rows) { if (row.length === 0) continue; - const rowLabel = row[0]?.content?.trim() || ""; - if (rowLabel) { - // Bold the row label - const start = state.text.length; - state.text += rowLabel; - state.styles.push({ start, end: state.text.length, style: "bold" }); + const rowLabel = row[0]; + if (rowLabel?.text) { + const labelStart = state.text.length; + appendCell(state, rowLabel); + const labelEnd = state.text.length; + if (labelEnd > labelStart) { + state.styles.push({ start: labelStart, end: labelEnd, style: "bold" }); + } state.text += "\n"; } // Add each column as a bullet point for (let i = 1; i < row.length; i++) { - const header = headers[i]?.trim() || `Column ${i}`; - const value = row[i]?.content?.trim() || ""; - if (value) { - state.text += `• ${header}: ${value}\n`; + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; + } else { + state.text += `Column ${i}: `; } + appendCell(state, value); + state.text += "\n"; } state.text += "\n"; } @@ -296,37 +379,77 @@ function renderTableAsBullets(state: RenderState) { // Simple table: just list headers and values for (const row of rows) { for (let i = 0; i < row.length; i++) { - const header = headers[i]?.trim() || ""; - const value = row[i]?.content?.trim() || ""; - if (header && value) { - state.text += `• ${header}: ${value}\n`; - } else if (value) { - state.text += `• ${value}\n`; + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; } + appendCell(state, value); + state.text += "\n"; } state.text += "\n"; } } } -function renderTableAsFlat(state: RenderState) { +function renderTableAsCode(state: RenderState) { if (!state.table) return; - const { headers, rows } = state.table; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); - // Render headers - for (const header of headers) { - state.text += header.trim() + "\t"; - } - if (headers.length > 0) { - state.text = state.text.trimEnd() + "\n"; - } + const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); + if (columnCount === 0) return; - // Render rows - for (const row of rows) { - for (const cell of row) { - state.text += cell.content.trim() + "\t"; + const widths = Array.from({ length: columnCount }, () => 0); + const updateWidths = (cells: TableCell[]) => { + for (let i = 0; i < columnCount; i += 1) { + const cell = cells[i]; + const width = cell?.text.length ?? 0; + if (widths[i] < width) widths[i] = width; } - state.text = state.text.trimEnd() + "\n"; + }; + updateWidths(headers); + for (const row of rows) updateWidths(row); + + const codeStart = state.text.length; + + const appendRow = (cells: TableCell[]) => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + state.text += " "; + const cell = cells[i]; + if (cell) appendCell(state, cell); + const pad = widths[i] - (cell?.text.length ?? 0); + if (pad > 0) state.text += " ".repeat(pad); + state.text += " |"; + } + state.text += "\n"; + }; + + const appendDivider = () => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + const dashCount = Math.max(3, widths[i]); + state.text += ` ${"-".repeat(dashCount)} |`; + } + state.text += "\n"; + }; + + appendRow(headers); + appendDivider(); + for (const row of rows) { + appendRow(row); + } + + const codeEnd = state.text.length; + if (codeEnd > codeStart) { + state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" }); + } + if (state.env.listStack.length === 0) { + state.text += "\n"; } } @@ -368,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { break; case "link_open": { const href = getAttr(token, "href") ?? ""; - state.env.linkStack.push({ href, labelStart: state.text.length }); + const target = resolveRenderTarget(state); + target.linkStack.push({ href, labelStart: target.text.length }); break; } case "link_close": @@ -428,15 +552,18 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { // Table handling case "table_open": - if (state.tableMode === "bullets") { + if (state.tableMode !== "off") { state.table = initTableState(); + state.hasTables = true; } break; case "table_close": - if (state.tableMode === "bullets" && state.table) { - renderTableAsBullets(state); - } else if (state.tableMode === "flat" && state.table) { - renderTableAsFlat(state); + if (state.table) { + if (state.tableMode === "bullets") { + renderTableAsBullets(state); + } else if (state.tableMode === "code") { + renderTableAsCode(state); + } } state.table = null; break; @@ -461,33 +588,24 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "tr_close": if (state.table) { if (state.table.inHeader) { - state.table.headers = state.table.currentRow.map((c) => c.content); + state.table.headers = state.table.currentRow; } else { state.table.rows.push(state.table.currentRow); } state.table.currentRow = []; - } else if (state.tableMode === "flat") { - // Legacy flat mode without table state - state.text += "\n"; } break; case "th_open": case "td_open": if (state.table) { - state.table.currentCell = ""; + state.table.currentCell = initRenderTarget(); } break; case "th_close": case "td_close": - if (state.table) { - state.table.currentRow.push({ - content: state.table.currentCell, - isHeader: token.type === "th_close", - }); - state.table.currentCell = ""; - } else if (state.tableMode === "flat") { - // Legacy flat mode without table state - state.text += "\t"; + if (state.table?.currentCell) { + state.table.currentRow.push(finishTableCell(state.table.currentCell)); + state.table.currentCell = null; } break; @@ -501,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { } } -function closeRemainingStyles(state: RenderState) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - const open = state.openStyles[i]; - const end = state.text.length; +function closeRemainingStyles(target: RenderTarget) { + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + const open = target.openStyles[i]; + const end = target.text.length; if (end > open.start) { - state.styles.push({ + target.styles.push({ start: open.start, end, style: open.style, }); } } - state.openStyles = []; + target.openStyles = []; } function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] { @@ -594,26 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number): } export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR { - const env: RenderEnv = { listStack: [], linkStack: [] }; + return markdownToIRWithMeta(markdown, options).ir; +} + +export function markdownToIRWithMeta( + markdown: string, + options: MarkdownParseOptions = {}, +): { ir: MarkdownIR; hasTables: boolean } { + const env: RenderEnv = { listStack: [] }; const md = createMarkdownIt(options); const tokens = md.parse(markdown ?? "", env as unknown as object); if (options.enableSpoilers) { applySpoilerTokens(tokens as MarkdownToken[]); } - const tableMode = options.tableMode ?? "flat"; + const tableMode = options.tableMode ?? "off"; const state: RenderState = { text: "", styles: [], openStyles: [], links: [], + linkStack: [], env, headingStyle: options.headingStyle ?? "none", blockquotePrefix: options.blockquotePrefix ?? "", enableSpoilers: options.enableSpoilers ?? false, tableMode, table: null, + hasTables: false, }; renderTokens(tokens as MarkdownToken[], state); @@ -631,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = { finalLength === state.text.length ? state.text : state.text.slice(0, finalLength); return { - text: finalText, - styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), - links: clampLinkSpans(state.links, finalLength), + ir: { + text: finalText, + styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), + links: clampLinkSpans(state.links, finalLength), + }, + hasTables: state.hasTables, }; } diff --git a/src/markdown/tables.ts b/src/markdown/tables.ts new file mode 100644 index 000000000..9ae2b750e --- /dev/null +++ b/src/markdown/tables.ts @@ -0,0 +1,34 @@ +import type { MarkdownTableMode } from "../config/types.base.js"; +import { markdownToIRWithMeta } from "./ir.js"; +import { renderMarkdownWithMarkers } from "./render.js"; + +const MARKDOWN_STYLE_MARKERS = { + bold: { open: "**", close: "**" }, + italic: { open: "_", close: "_" }, + strikethrough: { open: "~~", close: "~~" }, + code: { open: "`", close: "`" }, + code_block: { open: "```\n", close: "```" }, +} as const; + +export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string { + if (!markdown || mode === "off") return markdown; + const { ir, hasTables } = markdownToIRWithMeta(markdown, { + linkify: false, + autolink: false, + headingStyle: "none", + blockquotePrefix: "", + tableMode: mode, + }); + if (!hasTables) return markdown; + return renderMarkdownWithMarkers(ir, { + styleMarkers: MARKDOWN_STYLE_MARKERS, + escapeText: (text) => text, + buildLink: (link, text) => { + const href = link.href.trim(); + if (!href) return null; + const label = text.slice(link.start, link.end); + if (!label) return null; + return { start: link.start, end: link.end, open: "[", close: `](${href})` }; + }, + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 45a4681c7..72bb72422 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -73,6 +73,8 @@ export type { DmPolicy, DmConfig, GroupPolicy, + MarkdownConfig, + MarkdownTableMode, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, @@ -92,6 +94,8 @@ export { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, + MarkdownTableModeSchema, normalizeAllowFrom, requireOpenAllowFrom, } from "../config/zod-schema.core.js"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 4765c71c7..6bb10984b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -34,6 +34,7 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveStateDir } from "../../config/paths.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { @@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { probeIMessage } from "../../imessage/probe.js"; import { sendMessageIMessage } from "../../imessage/send.js"; import { shouldLogVerbose } from "../../globals.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import { getChildLogger } from "../../logging.js"; import { normalizeLogLevel } from "../../logging/levels.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; @@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime { chunkText, resolveTextChunkLimit, hasControlCommand, + resolveMarkdownTableMode, + convertMarkdownTables, }, reply: { dispatchReplyWithBufferedBlockDispatcher, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 089e20c37..1f321d04b 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers = type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; +type ResolveMarkdownTableMode = + typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode; +type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables; type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand; type IsControlCommandMessage = typeof import("../../auto-reply/command-detection.js").isControlCommandMessage; @@ -168,6 +171,8 @@ export type PluginRuntime = { chunkText: ChunkText; resolveTextChunkLimit: ResolveTextChunkLimit; hasControlCommand: HasControlCommand; + resolveMarkdownTableMode: ResolveMarkdownTableMode; + convertMarkdownTables: ConvertMarkdownTables; }; reply: { dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; diff --git a/src/signal/format.ts b/src/signal/format.ts index 0890ce608..127884e89 100644 --- a/src/signal/format.ts +++ b/src/signal/format.ts @@ -4,6 +4,7 @@ import { type MarkdownIR, type MarkdownStyle, } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; @@ -18,6 +19,10 @@ export type SignalFormattedText = { styles: SignalTextStyleRange[]; }; +type SignalMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + type SignalStyleSpan = { start: number; end: number; @@ -188,22 +193,31 @@ function renderSignalText(ir: MarkdownIR): SignalFormattedText { }; } -export function markdownToSignalText(markdown: string): SignalFormattedText { +export function markdownToSignalText( + markdown: string, + options: SignalMarkdownOptions = {}, +): SignalFormattedText { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); return renderSignalText(ir); } -export function markdownToSignalTextChunks(markdown: string, limit: number): SignalFormattedText[] { +export function markdownToSignalTextChunks( + markdown: string, + limit: number, + options: SignalMarkdownOptions = {}, +): SignalFormattedText[] { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => renderSignalText(chunk)); diff --git a/src/signal/send.ts b/src/signal/send.ts index dce4cda7a..32ca09094 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; @@ -164,7 +165,12 @@ export async function sendMessageSignal( if (textMode === "plain") { textStyles = opts.textStyles ?? []; } else { - const formatted = markdownToSignalText(message); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "signal", + accountId: accountInfo.accountId, + }); + const formatted = markdownToSignalText(message, { tableMode }); message = formatted.text; textStyles = formatted.styles; } diff --git a/src/slack/format.ts b/src/slack/format.ts index 575841921..7f44b5df2 100644 --- a/src/slack/format.ts +++ b/src/slack/format.ts @@ -1,4 +1,5 @@ import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; // Escape special characters for Slack mrkdwn format. @@ -83,12 +84,20 @@ function buildSlackLink(link: MarkdownLinkSpan, text: string) { }; } -export function markdownToSlackMrkdwn(markdown: string): string { +type SlackMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + +export function markdownToSlackMrkdwn( + markdown: string, + options: SlackMarkdownOptions = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); return renderMarkdownWithMarkers(ir, { styleMarkers: { @@ -103,12 +112,17 @@ export function markdownToSlackMrkdwn(markdown: string): string { }); } -export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] { +export function markdownToSlackMrkdwnChunks( + markdown: string, + limit: number, + options: SlackMarkdownOptions = {}, +): string[] { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index 59c9d8164..ca4635123 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -1,6 +1,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import type { RuntimeEnv } from "../../runtime.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack } from "../send.js"; @@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: { respond: SlackRespondFn; ephemeral: boolean; textLimit: number; + tableMode?: MarkdownTableMode; }) { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); @@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: { .filter(Boolean) .join("\n"); if (!combined) continue; - for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) { + for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, { + tableMode: params.tableMode, + })) { messages.push(chunk); } } diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d8e97dd43..8f290d892 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); }, onError: (err, info) => { @@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); } } catch (err) { diff --git a/src/slack/send.ts b/src/slack/send.ts index 06de9770d..3759b2826 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { parseSlackTarget } from "./targets.js"; import { resolveSlackBotToken } from "./token.js"; @@ -143,7 +144,12 @@ export async function sendMessageSlack( const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: account.accountId, + }); + const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode }); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 79f57a28c..4afbaa653 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({ let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, onVoiceRecording: sendRecordVoice, }); }, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 11e83dcc3..c3d3a7b74 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; @@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({ id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const systemPromptParts = [ groupConfig?.systemPrompt?.trim() || null, @@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, }); }, onError: (err, info) => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index cb6356061..e05b224da 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js"; import { splitTelegramCaption } from "../caption.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -26,6 +27,7 @@ export async function deliverReplies(params: { replyToMode: ReplyToMode; textLimit: number; messageThreadId?: number; + tableMode?: MarkdownTableMode; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; }) { @@ -49,7 +51,9 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { - const chunks = markdownToTelegramChunks(reply.text || "", textLimit); + const chunks = markdownToTelegramChunks(reply.text || "", textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: @@ -139,7 +143,9 @@ export async function deliverReplies(params: { // Send deferred follow-up text right after the first media item. // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { - const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); + const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 8b08a35f0..b0472c69c 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -5,6 +5,7 @@ import { type MarkdownIR, } from "../markdown/ir.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; export type TelegramFormattedChunk = { html: string; @@ -46,12 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string { }); } -export function markdownToTelegramHtml(markdown: string): string { +export function markdownToTelegramHtml( + markdown: string, + options: { tableMode?: MarkdownTableMode } = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", - tableMode: "bullets", + tableMode: options.tableMode, }); return renderTelegramHtml(ir); } @@ -59,12 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string { export function markdownToTelegramChunks( markdown: string, limit: number, + options: { tableMode?: MarkdownTableMode } = {}, ): TelegramFormattedChunk[] { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", - tableMode: "bullets", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => ({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 253db203e..01120d354 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -310,7 +311,12 @@ export async function sendMessageTelegram( throw new Error("Message must be non-empty for Telegram sends"); } const textMode = opts.textMode ?? "markdown"; - const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text, { tableMode }); const textParams = hasThreadParams ? { parse_mode: "HTML" as const, diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 294589548..2204a9e8f 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -1,4 +1,6 @@ import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { loadWebMedia } from "../media.js"; @@ -19,10 +21,13 @@ export async function deliverWebReply(params: { }; connectionId?: string; skipLog?: boolean; + tableMode?: MarkdownTableMode; }) { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); - const textChunks = chunkMarkdownText(replyResult.text || "", textLimit); + const tableMode = params.tableMode ?? "code"; + const convertedText = convertMarkdownTables(replyResult.text || "", tableMode); + const textChunks = chunkMarkdownText(convertedText, textLimit); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index ea9895853..c1d280a65 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -28,6 +28,7 @@ import { recordSessionMetaFromInbound, resolveStorePath, } from "../../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; @@ -235,6 +236,11 @@ export async function processMessage(params: { : undefined; const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); + const tableMode = resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + }); let didLogHeartbeatStrip = false; let didSendReply = false; const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg) @@ -345,6 +351,7 @@ export async function processMessage(params: { connectionId: params.connectionId, // Tool + block updates are noisy; skip their log lines. skipLog: info.kind !== "final", + tableMode, }); didSendReply = true; if (info.kind === "tool") { diff --git a/src/web/outbound.ts b/src/web/outbound.ts index d67abb2a1..0ca867961 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -4,6 +4,9 @@ import { getChildLogger } from "../logging/logger.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; +import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; @@ -25,6 +28,13 @@ export async function sendMessageWhatsApp( const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( options.accountId, ); + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "whatsapp", + accountId: resolvedAccountId ?? options.accountId, + }); + text = convertMarkdownTables(text ?? "", tableMode); const logger = getChildLogger({ module: "web-outbound", correlationId,