fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0)

This commit is contained in:
Peter Steinberger
2026-01-23 17:56:50 +00:00
parent 37e5f077b8
commit b77e730657
64 changed files with 837 additions and 186 deletions

View File

@@ -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<string, MarkdownConfigEntry>;
};
const DEFAULT_TABLE_MODES = new Map<string, MarkdownTableMode>([
["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<ClawdbotConfig>;
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<string, unknown> | undefined;
const section = (channelsConfig?.[channel] ??
(params.cfg as Record<string, unknown> | undefined)?.[channel]) as
| MarkdownConfigSection
| undefined;
return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
}

View File

@@ -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";

View File

@@ -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). */

View File

@@ -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. */

View File

@@ -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). */

View File

@@ -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. */

View File

@@ -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). */

View File

@@ -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). */

View File

@@ -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<string, WhatsAppAccountConfig>;
/** 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. */

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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"),

View File

@@ -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();
},

View File

@@ -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;

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -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<string, unknown> = {
text: message,

View File

@@ -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: [],
};

View File

@@ -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);
});
});

View File

@@ -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,
};
}

34
src/markdown/tables.ts Normal file
View File

@@ -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})` };
},
});
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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,
});
},

View File

@@ -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) => {

View File

@@ -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> | 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;

View File

@@ -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) => ({

View File

@@ -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,

View File

@@ -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

View File

@@ -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") {

View File

@@ -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,