Compare commits

..
Author SHA1 Message Date
Peter Steinberger 862e89e683 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 17:56:50 +00:00
George Zhang 03384e4b80 feat(telegram): convert markdown tables to bullet points
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 12:00:26 +00:00
George Zhang bfbeea0f20 daemon: prefer symlinked paths over realpath for stable service configs (#1505)
When installing the LaunchAgent/systemd service, the CLI was using
fs.realpath() to resolve the entry.js path, which converted stable
symlinked paths (e.g. node_modules/clawdbot) into version-specific
paths (e.g. .pnpm/clawdbot@X.Y.Z/...).

This caused the service to break after pnpm updates because the old
versioned path no longer exists, even though the symlink still works.

Now we prefer the original (symlinked) path when it's valid, keeping
service configs stable across package version updates.
2026-01-23 11:52:26 +00:00
Peter Steinberger 2c85b1b409 fix: restart gateway after update by default 2026-01-23 11:50:19 +00:00
68 changed files with 1048 additions and 108 deletions

No files matched your search

+4 -1
View File
@@ -4,10 +4,13 @@ Docs: https://docs.clawd.bot
## 2026.1.23 ## 2026.1.23
### 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 ### Fixes
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Daemon: prefer symlinked CLI paths to keep service configs stable. (#1505) Thanks @odysseus0.
## 2026.1.22 ## 2026.1.22
+5 -4
View File
@@ -1,5 +1,5 @@
--- ---
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)" summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
read_when: read_when:
- You want to update a source checkout safely - You want to update a source checkout safely
- You need to understand `--update` shorthand behavior - You need to understand `--update` shorthand behavior
@@ -20,14 +20,14 @@ clawdbot update wizard
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
clawdbot update --restart clawdbot update --no-restart
clawdbot update --json clawdbot update --json
clawdbot --update clawdbot --update
``` ```
## Options ## Options
- `--restart`: restart the Gateway service after a successful update. - `--no-restart`: skip restarting the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config). - `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only. - `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON. - `--json`: print machine-readable `UpdateRunResult` JSON.
@@ -52,7 +52,8 @@ Options:
## `update wizard` ## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating. If you select `dev` without a git checkout, it offers to create one. after updating (default is to restart). If you select `dev` without a git checkout, it
offers to create one.
## What it does ## What it does
+25 -1
View File
@@ -25,6 +25,7 @@ stay consistent across channels.
1. **Parse Markdown -> IR** 1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. - 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. - 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)** 2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering. - Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk. - 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. - Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or - 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 ## Chunking rules
+3 -2
View File
@@ -7,7 +7,7 @@ read_when:
# Updating # Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify. Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
## Recommended: re-run the website installer (upgrade in place) ## Recommended: re-run the website installer (upgrade in place)
@@ -81,7 +81,7 @@ Notes:
For **source installs** (git checkout), prefer: For **source installs** (git checkout), prefer:
```bash ```bash
clawdbot update --restart clawdbot update
``` ```
It runs a safe-ish update flow: It runs a safe-ish update flow:
@@ -89,6 +89,7 @@ It runs a safe-ish update flow:
- Switches to the selected channel (tag or branch). - Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel). - Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. - Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
- Restarts the gateway by default (use `--no-restart` to skip).
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead. If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({
const bluebubblesAccountSchema = z.object({ const bluebubblesAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(), serverUrl: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
@@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime {
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], 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: { reply: {
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
+15 -3
View File
@@ -1662,9 +1662,15 @@ async function processMessage(
? [payload.mediaUrl] ? [payload.mediaUrl]
: []; : [];
if (mediaList.length > 0) { 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; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
const result = await sendBlueBubblesMedia({ const result = await sendBlueBubblesMedia({
cfg: config, cfg: config,
@@ -1686,8 +1692,14 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0 account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit ? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT; : DEFAULT_TEXT_LIMIT;
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); const tableMode = core.channel.text.resolveMarkdownTableMode({
if (!chunks.length && payload.text) chunks.push(payload.text); 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; if (!chunks.length) return;
for (const chunk of chunks) { for (const chunk of chunks) {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, { const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -35,6 +36,7 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({ export const MatrixConfigSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(), homeserver: z.string().optional(),
userId: z.string().optional(), userId: z.string().optional(),
accessToken: z.string().optional(), accessToken: z.string().optional(),
@@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
let didSendReply = false; let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: route.accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
@@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit, textLimit,
replyToMode, replyToMode,
threadId: threadTarget, threadId: threadTarget,
accountId: route.accountId,
tableMode,
}); });
didSendReply = true; didSendReply = true;
}, },
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk"; 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 { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
@@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: {
textLimit: number; textLimit: number;
replyToMode: "off" | "first" | "all"; replyToMode: "off" | "first" | "all";
threadId?: string; threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const core = getMatrixRuntime(); const core = getMatrixRuntime();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "matrix",
accountId: params.accountId,
});
const logVerbose = (message: string) => { const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) { if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message); params.runtime.log?.(message);
@@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: {
} }
const replyToIdRaw = reply.replyToId?.trim(); const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; 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 const mediaList = reply.mediaUrls?.length
? reply.mediaUrls ? reply.mediaUrls
: reply.mediaUrl : reply.mediaUrl
@@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied); Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) { 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(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
client: params.client, client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
accountId: params.accountId,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? (reply.text ?? "") : ""; const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, { await sendMessageMatrix(params.roomId, caption, {
client: params.client, client: params.client,
mediaUrl, mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
audioAsVoice: reply.audioAsVoice, audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -43,6 +43,8 @@ const runtimeStub = {
text: { text: {
resolveTextChunkLimit: () => 4000, resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []), chunkMarkdownText: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
+10 -1
View File
@@ -50,9 +50,18 @@ export async function sendMessageMatrix(
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig(); 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 textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); 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 threadId = normalizeThreadId(opts.threadId);
const relation = threadId const relation = threadId
? buildThreadRelation(threadId, opts.replyToId) ? buildThreadRelation(threadId, opts.replyToId)
@@ -87,6 +87,7 @@ export type MatrixSendResult = {
export type MatrixSendOpts = { export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient; client?: import("matrix-bot-sdk").MatrixClient;
mediaUrl?: string; mediaUrl?: string;
accountId?: string;
replyToId?: string; replyToId?: string;
threadId?: string | number | null; threadId?: string | number | null;
timeoutMs?: number; timeoutMs?: number;
@@ -4,6 +4,7 @@ import {
BlockStreamingCoalesceSchema, BlockStreamingCoalesceSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
botToken: z.string().optional(), botToken: z.string().optional(),
@@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000, fallbackLimit: account.textChunkLimit ?? 4000,
}); });
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
let prefixContext: ResponsePrefixContext = { let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId), identityName: resolveIdentityName(cfg, route.agentId),
@@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => { deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); 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) { if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit); const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) { for (const chunk of chunks.length > 0 ? chunks : [text]) {
+10 -1
View File
@@ -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 (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) { if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`); throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
@@ -205,4 +214,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown", messageId: post.id ?? "unknown",
channelId, channelId,
}; };
} }
+7 -3
View File
@@ -21,6 +21,8 @@ const runtimeStub = {
} }
return chunks; return chunks;
}, },
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
@@ -34,6 +36,7 @@ describe("msteams messenger", () => {
it("filters silent replies", () => { it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000, textChunkLimit: 4000,
tableMode: "code",
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
@@ -41,7 +44,7 @@ describe("msteams messenger", () => {
it("filters silent reply prefixes", () => { it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000 }, { textChunkLimit: 4000, tableMode: "code" },
); );
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
@@ -49,7 +52,7 @@ describe("msteams messenger", () => {
it("splits media into separate messages by default", () => { it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ 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" }]); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -57,7 +60,7 @@ describe("msteams messenger", () => {
it("supports inline media mode", () => { it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ 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" }]); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -66,6 +69,7 @@ describe("msteams messenger", () => {
const long = "hello ".repeat(200); const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], { const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50, textChunkLimit: 50,
tableMode: "code",
}); });
expect(messages.length).toBeGreaterThan(1); expect(messages.length).toBeGreaterThan(1);
}); });
+12 -1
View File
@@ -1,6 +1,7 @@
import { import {
isSilentReplyText, isSilentReplyText,
loadWebMedia, loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
@@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number; textChunkLimit: number;
chunkText?: boolean; chunkText?: boolean;
mediaMode?: "split" | "inline"; mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
}; };
/** /**
@@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages(
const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false; const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split"; const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg: getMSTeamsRuntime().config.loadConfig(),
channel: "msteams",
});
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); 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; if (!text && mediaList.length === 0) continue;
@@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: {
).responsePrefix, ).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => { deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], { const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit, textChunkLimit: params.textLimit,
chunkText: true, chunkText: true,
mediaMode: "split", mediaMode: "split",
tableMode,
}); });
const mediaMaxBytes = resolveChannelMediaMaxBytes({ const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg, cfg: params.cfg,
+16 -7
View File
@@ -16,6 +16,7 @@ import {
import { extractFilename, extractMessageId } from "./media-helpers.js"; import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js"; import { buildMSTeamsPollCard } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = { export type SendMSTeamsMessageParams = {
@@ -93,13 +94,21 @@ export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams, params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> { ): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params; 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 ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx; const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
log.debug("sending proactive message", { log.debug("sending proactive message", {
conversationId, conversationId,
conversationType, conversationType,
textLength: text.length, textLength: messageText.length,
hasMedia: Boolean(mediaUrl), hasMedia: Boolean(mediaUrl),
}); });
@@ -134,7 +143,7 @@ export async function sendMessageMSTeams(
const { activity, uploadId } = prepareFileConsentActivity({ const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId, conversationId,
description: text || undefined, description: messageText || undefined,
}); });
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); 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 base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`; const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl); return sendTextWithMedia(ctx, messageText, finalMediaUrl);
} }
if (isImage && !sharePointSiteId) { if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64"); const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${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 // 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 fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = { const activity = {
type: "message", type: "message",
text: text || undefined, text: messageText || undefined,
attachments: [fileCardAttachment], attachments: [fileCardAttachment],
}; };
@@ -264,7 +273,7 @@ export async function sendMessageMSTeams(
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = { const activity = {
type: "message", type: "message",
text: text ? `${text}\n\n${fileLink}` : fileLink, text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
}; };
const baseRef = buildConversationReference(ref); const baseRef = buildConversationReference(ref);
@@ -290,7 +299,7 @@ export async function sendMessageMSTeams(
} }
// No media: send text only // No media: send text only
return sendTextWithMedia(ctx, text, undefined); return sendTextWithMedia(ctx, messageText, undefined);
} }
/** /**
@@ -3,6 +3,7 @@ import {
DmConfigSchema, DmConfigSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
@@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(), baseUrl: z.string().optional(),
botSecret: z.string().optional(), botSecret: z.string().optional(),
botSecretFile: z.string().optional(), botSecretFile: z.string().optional(),
+11 -1
View File
@@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk(
throw new Error("Message must be non-empty for Nextcloud Talk sends"); 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<string, unknown> = { const body: Record<string, unknown> = {
message: text.trim(), message,
}; };
if (opts.replyTo) { if (opts.replyTo) {
body.replyTo = opts.replyTo; body.replyTo = opts.replyTo;
+8 -1
View File
@@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => { sendText: async ({ to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID; const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid); const bus = activeBuses.get(aid);
if (!bus) { if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`); 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); const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, text); await bus.sendDm(normalizedTo, message);
return { channel: "nostr", to: normalizedTo }; return { channel: "nostr", to: normalizedTo };
}, },
}, },
+4 -1
View File
@@ -1,5 +1,5 @@
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */ /** Whether this channel is enabled */
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
/** Private key in hex or nsec bech32 format */ /** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(), privateKey: z.string().optional(),
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({ const zaloAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(), botToken: z.string().optional(),
tokenFile: z.string().optional(), tokenFile: z.string().optional(),
webhookUrl: z.string().optional(), webhookUrl: z.string().optional(),
+14 -4
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; 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 type { ResolvedZaloAccount } from "./accounts.js";
import { import {
@@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); 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({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg: config, cfg: config,
@@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: {
core, core,
statusSink, statusSink,
fetcher, fetcher,
tableMode,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -608,8 +615,11 @@ async function deliverZaloReply(params: {
core: ZaloCoreRuntime; core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch; fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params; 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 const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
@@ -620,7 +630,7 @@ async function deliverZaloReply(params: {
if (mediaList.length > 0) { if (mediaList.length > 0) {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
try { try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
@@ -632,8 +642,8 @@ async function deliverZaloReply(params: {
return; return;
} }
if (payload.text) { if (text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT); const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -10,6 +11,7 @@ const groupConfigSchema = z.object({
const zalouserAccountSchema = z.object({ const zalouserAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(), profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(), allowFrom: z.array(allowFromEntry).optional(),
+12 -4
View File
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process"; 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 { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
import { sendMessageZalouser } from "./send.js"; import { sendMessageZalouser } from "./send.js";
import type { import type {
@@ -332,6 +332,11 @@ async function processMessage(
runtime, runtime,
core, core,
statusSink, statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
}),
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -351,8 +356,11 @@ async function deliverZalouserReply(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
core: ZalouserCoreRuntime; core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params; 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 const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
@@ -363,7 +371,7 @@ async function deliverZalouserReply(params: {
if (mediaList.length > 0) { if (mediaList.length > 0) {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
try { try {
logVerbose(core, runtime, `Sending media to ${chatId}`); logVerbose(core, runtime, `Sending media to ${chatId}`);
@@ -380,8 +388,8 @@ async function deliverZalouserReply(params: {
return; return;
} }
if (payload.text) { if (text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT); const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
+21 -2
View File
@@ -415,7 +415,7 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1); expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
}); });
it("updateCommand restarts daemon when --restart is set", async () => { it("updateCommand restarts daemon by default", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js"); const { runDaemonRestart } = await import("./daemon-cli.js");
const { updateCommand } = await import("./update-cli.js"); const { updateCommand } = await import("./update-cli.js");
@@ -430,11 +430,30 @@ describe("update-cli", () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runDaemonRestart).mockResolvedValue(true); vi.mocked(runDaemonRestart).mockResolvedValue(true);
await updateCommand({ restart: true }); await updateCommand({});
expect(runDaemonRestart).toHaveBeenCalled(); expect(runDaemonRestart).toHaveBeenCalled();
}); });
it("updateCommand skips restart when --no-restart is set", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js");
const { updateCommand } = await import("./update-cli.js");
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
await updateCommand({ restart: false });
expect(runDaemonRestart).not.toHaveBeenCalled();
});
it("updateCommand skips success message when restart does not run", async () => { it("updateCommand skips success message when restart does not run", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { runDaemonRestart } = await import("./daemon-cli.js"); const { runDaemonRestart } = await import("./daemon-cli.js");
+5 -4
View File
@@ -553,6 +553,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
process.noDeprecation = true; process.noDeprecation = true;
process.env.NODE_NO_WARNINGS = "1"; process.env.NODE_NO_WARNINGS = "1";
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined; const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
const shouldRestart = opts.restart !== false;
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) { if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
defaultRuntime.error("--timeout must be a positive integer (seconds)"); defaultRuntime.error("--timeout must be a positive integer (seconds)");
@@ -898,7 +899,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} }
// Restart service if requested // Restart service if requested
if (opts.restart) { if (shouldRestart) {
if (!opts.json) { if (!opts.json) {
defaultRuntime.log(""); defaultRuntime.log("");
defaultRuntime.log(theme.heading("Restarting service...")); defaultRuntime.log(theme.heading("Restarting service..."));
@@ -1068,7 +1069,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi
const restart = await confirm({ const restart = await confirm({
message: stylePromptMessage("Restart the gateway service after update?"), message: stylePromptMessage("Restart the gateway service after update?"),
initialValue: false, initialValue: true,
}); });
if (isCancel(restart)) { if (isCancel(restart)) {
defaultRuntime.log(theme.muted("Update cancelled.")); defaultRuntime.log(theme.muted("Update cancelled."));
@@ -1093,7 +1094,7 @@ export function registerUpdateCli(program: Command) {
.command("update") .command("update")
.description("Update Clawdbot to the latest version") .description("Update Clawdbot to the latest version")
.option("--json", "Output result as JSON", false) .option("--json", "Output result as JSON", false)
.option("--restart", "Restart the gateway service after a successful update", false) .option("--no-restart", "Skip restarting the gateway service after a successful update")
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)") .option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update") .option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)") .option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
@@ -1104,7 +1105,7 @@ export function registerUpdateCli(program: Command) {
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"], ["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"], ["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
["clawdbot update --tag beta", "One-off update to a dist-tag or version"], ["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
["clawdbot update --restart", "Update and restart the service"], ["clawdbot update --no-restart", "Update without restarting the service"],
["clawdbot update --json", "Output result as JSON"], ["clawdbot update --json", "Output result as JSON"],
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"], ["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
["clawdbot update wizard", "Interactive update wizard"], ["clawdbot update wizard", "Interactive update wizard"],
+60
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;
}
+7
View File
@@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = {
breakPreference?: "paragraph" | "newline" | "sentence"; breakPreference?: "paragraph" | "newline" | "sentence";
}; };
export type MarkdownTableMode = "off" | "bullets" | "code";
export type MarkdownConfig = {
/** Table rendering mode (off|bullets|code). */
tables?: MarkdownTableMode;
};
export type HumanDelayConfig = { export type HumanDelayConfig = {
/** Delay style for block replies (off|natural|custom). */ /** Delay style for block replies (off|natural|custom). */
mode?: "off" | "natural" | "custom"; mode?: "off" | "natural" | "custom";
+3
View File
@@ -2,6 +2,7 @@ import type {
BlockStreamingCoalesceConfig, BlockStreamingCoalesceConfig,
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig,
OutboundRetryConfig, OutboundRetryConfig,
ReplyToMode, ReplyToMode,
} from "./types.base.js"; } from "./types.base.js";
@@ -70,6 +71,8 @@ export type DiscordAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Discord (bool or "auto"). */ /** Override native command registration for Discord (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
+8 -1
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"; import type { DmConfig } from "./types.messages.js";
export type IMessageAccountConfig = { export type IMessageAccountConfig = {
@@ -6,6 +11,8 @@ export type IMessageAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this iMessage account. Default: true. */ /** If false, do not start this iMessage account. Default: true. */
+8 -1
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"; import type { DmConfig } from "./types.messages.js";
export type MSTeamsWebhookConfig = { export type MSTeamsWebhookConfig = {
@@ -34,6 +39,8 @@ export type MSTeamsConfig = {
enabled?: boolean; enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** Azure Bot App ID (from Azure Bot registration). */ /** Azure Bot App ID (from Azure Bot registration). */
+8 -1
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"; import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
@@ -8,6 +13,8 @@ export type SignalAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this Signal account. Default: true. */ /** If false, do not start this Signal account. Default: true. */
+3
View File
@@ -2,6 +2,7 @@ import type {
BlockStreamingCoalesceConfig, BlockStreamingCoalesceConfig,
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig,
ReplyToMode, ReplyToMode,
} from "./types.base.js"; } from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
@@ -80,6 +81,8 @@ export type SlackAccountConfig = {
webhookPath?: string; webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Slack (bool or "auto"). */ /** Override native command registration for Slack (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
+3
View File
@@ -3,6 +3,7 @@ import type {
BlockStreamingCoalesceConfig, BlockStreamingCoalesceConfig,
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig,
OutboundRetryConfig, OutboundRetryConfig,
ReplyToMode, ReplyToMode,
} from "./types.base.js"; } from "./types.base.js";
@@ -35,6 +36,8 @@ export type TelegramAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: TelegramCapabilitiesConfig; capabilities?: TelegramCapabilitiesConfig;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Telegram (bool or "auto"). */ /** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Custom commands to register in Telegram's command menu (merged with native). */ /** Custom commands to register in Telegram's command menu (merged with native). */
+10 -1
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"; import type { DmConfig } from "./types.messages.js";
export type WhatsAppActionConfig = { export type WhatsAppActionConfig = {
@@ -12,6 +17,8 @@ export type WhatsAppConfig = {
accounts?: Record<string, WhatsAppAccountConfig>; accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** Send read receipts for incoming messages (default true). */ /** Send read receipts for incoming messages (default true). */
@@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Allow channel-initiated config writes (default: true). */ /** Allow channel-initiated config writes (default: true). */
configWrites?: boolean; configWrites?: boolean;
/** If false, do not start this WhatsApp account provider. Default: true. */ /** If false, do not start this WhatsApp account provider. Default: true. */
+9
View File
@@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z
}) })
.strict(); .strict();
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
export const MarkdownConfigSchema = z
.object({
tables: MarkdownTableModeSchema.optional(),
})
.strict()
.optional();
export const HumanDelaySchema = z export const HumanDelaySchema = z
.object({ .object({
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
+8
View File
@@ -7,6 +7,7 @@ import {
DmPolicySchema, DmPolicySchema,
ExecutableTokenSchema, ExecutableTokenSchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
MSTeamsReplyStyleSchema, MSTeamsReplyStyleSchema,
ProviderCommandsSchema, ProviderCommandsSchema,
ReplyToModeSchema, ReplyToModeSchema,
@@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: TelegramCapabilitiesSchema.optional(), capabilities: TelegramCapabilitiesSchema.optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
customCommands: z.array(TelegramCustomCommandSchema).optional(), customCommands: z.array(TelegramCustomCommandSchema).optional(),
@@ -193,6 +195,7 @@ export const DiscordAccountSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
@@ -296,6 +299,7 @@ export const SlackAccountSchema = z
signingSecret: z.string().optional(), signingSecret: z.string().optional(),
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
@@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
account: z.string().optional(), account: z.string().optional(),
@@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(), cliPath: ExecutableTokenSchema.optional(),
@@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
serverUrl: z.string().optional(), serverUrl: z.string().optional(),
@@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
appId: z.string().optional(), appId: z.string().optional(),
appPassword: z.string().optional(), appPassword: z.string().optional(),
@@ -5,12 +5,14 @@ import {
DmConfigSchema, DmConfigSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
} from "./zod-schema.core.js"; } from "./zod-schema.core.js";
export const WhatsAppAccountSchema = z export const WhatsAppAccountSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(), sendReadReceipts: z.boolean().optional(),
@@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z
.object({ .object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(), sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
@@ -27,6 +27,7 @@ import {
resolveStorePath, resolveStorePath,
updateLastRoute, updateLastRoute,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js";
@@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
let prefixContext: ResponsePrefixContext = { let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId), identityName: resolveIdentityName(cfg, route.agentId),
}; };
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
@@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
replyToId, replyToId,
textLimit, textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage, maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
}); });
replyReference.markSent(); replyReference.markSent();
}, },
+6 -1
View File
@@ -1,6 +1,8 @@
import type { RequestClient } from "@buape/carbon"; import type { RequestClient } from "@buape/carbon";
import type { ReplyPayload } from "../../auto-reply/types.js"; 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 type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordText } from "../chunk.js"; import { chunkDiscordText } from "../chunk.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
@@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: {
textLimit: number; textLimit: number;
maxLinesPerMessage?: number; maxLinesPerMessage?: number;
replyToId?: string; replyToId?: string;
tableMode?: MarkdownTableMode;
}) { }) {
const chunkLimit = Math.min(params.textLimit, 2000); const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) { for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); 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; if (!text && mediaList.length === 0) continue;
const replyTo = params.replyToId?.trim() || undefined; const replyTo = params.replyToId?.trim() || undefined;
+10 -2
View File
@@ -1,7 +1,9 @@
import type { RequestClient } from "@buape/carbon"; import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10"; import { Routes } from "discord-api-types/v10";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
import { convertMarkdownTables } from "../markdown/tables.js";
import type { RetryConfig } from "../infra/retry.js"; import type { RetryConfig } from "../infra/retry.js";
import type { PollInput } from "../polls.js"; import type { PollInput } from "../polls.js";
import { resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordAccount } from "./accounts.js";
@@ -38,6 +40,12 @@ export async function sendMessageDiscord(
cfg, cfg,
accountId: opts.accountId, 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 { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to); const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient, request); const { channelId } = await resolveChannelId(rest, recipient, request);
@@ -47,7 +55,7 @@ export async function sendMessageDiscord(
result = await sendDiscordMedia( result = await sendDiscordMedia(
rest, rest,
channelId, channelId,
text, textWithTables,
opts.mediaUrl, opts.mediaUrl,
opts.replyTo, opts.replyTo,
request, request,
@@ -58,7 +66,7 @@ export async function sendMessageDiscord(
result = await sendDiscordText( result = await sendDiscordText(
rest, rest,
channelId, channelId,
text, textWithTables,
opts.replyTo, opts.replyTo,
request, request,
accountInfo.config.maxLinesPerMessage, accountInfo.config.maxLinesPerMessage,
+11 -1
View File
@@ -1,4 +1,7 @@
import { chunkText } from "../../auto-reply/chunk.js"; 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 { ReplyPayload } from "../../auto-reply/types.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import type { createIMessageRpcClient } from "../client.js"; import type { createIMessageRpcClient } from "../client.js";
@@ -14,9 +17,16 @@ export async function deliverReplies(params: {
textLimit: number; textLimit: number;
}) { }) {
const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params; const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
const cfg = loadConfig();
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "imessage",
accountId,
});
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); 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 (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) { for (const chunk of chunkText(text, textLimit)) {
+10
View File
@@ -1,7 +1,9 @@
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { convertMarkdownTables } from "../markdown/tables.js";
import { resolveIMessageAccount } from "./accounts.js"; import { resolveIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
@@ -88,6 +90,14 @@ export async function sendMessageIMessage(
if (!message.trim() && !filePath) { if (!message.trim() && !filePath) {
throw new Error("iMessage send requires text or media"); 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> = { const params: Record<string, unknown> = {
text: message, text: message,
+11 -3
View File
@@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import type { sendMessageDiscord } from "../../discord/send.js"; import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js"; import type { sendMessageIMessage } from "../../imessage/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
@@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: {
}) })
: undefined; : undefined;
const isSignalChannel = channel === "signal"; const isSignalChannel = channel === "signal";
const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
: "code";
const signalMaxBytes = isSignalChannel const signalMaxBytes = isSignalChannel
? resolveChannelMediaMaxBytes({ ? resolveChannelMediaMaxBytes({
cfg, cfg,
@@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
let signalChunks = let signalChunks =
textLimit === undefined textLimit === undefined
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY) ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
: markdownToSignalTextChunks(text, textLimit); tableMode: signalTableMode,
})
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode });
if (signalChunks.length === 0 && text) { if (signalChunks.length === 0 && text) {
signalChunks = [{ text, styles: [] }]; signalChunks = [{ text, styles: [] }];
} }
@@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: {
const sendSignalMedia = async (caption: string, mediaUrl: string) => { const sendSignalMedia = async (caption: string, mediaUrl: string) => {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? { const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, {
tableMode: signalTableMode,
})[0] ?? {
text: caption, text: caption,
styles: [], styles: [],
}; };
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { markdownToIR } from "./ir.js";
describe("markdownToIR tableMode bullets", () => {
it("converts simple table to bullets", () => {
const md = `
| Name | Value |
|------|-------|
| A | 1 |
| B | 2 |
`.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");
// Should use first column as labels
expect(ir.text).toContain("A");
expect(ir.text).toContain("B");
});
it("handles table with multiple columns", () => {
const md = `
| Feature | SQLite | Postgres |
|---------|--------|----------|
| Speed | Fast | Medium |
| Scale | Small | Large |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// First column becomes row label
expect(ir.text).toContain("Speed");
expect(ir.text).toContain("Scale");
// Other columns become bullet points
expect(ir.text).toContain("• SQLite: Fast");
expect(ir.text).toContain("• Postgres: Medium");
expect(ir.text).toContain("• SQLite: Small");
expect(ir.text).toContain("• Postgres: Large");
});
it("leaves table syntax untouched by default", () => {
const md = `
| A | B |
|---|---|
| 1 | 2 |
`.trim();
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", () => {
const md = `
| Name | Value |
|------|-------|
| A | |
| B | 2 |
`.trim();
const ir = markdownToIR(md, { tableMode: "bullets" });
// Should handle empty cell without crashing
expect(ir.text).toContain("B");
expect(ir.text).toContain("• Value: 2");
});
it("bolds row labels in bullets mode", () => {
const md = `
| Name | Value |
|------|-------|
| Row1 | Data1 |
`.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",
);
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);
});
});
+342 -40
View File
@@ -1,6 +1,7 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { chunkText } from "../auto-reply/chunk.js"; import { chunkText } from "../auto-reply/chunk.js";
import type { MarkdownTableMode } from "../config/types.base.js";
type ListState = { type ListState = {
type: "bullet" | "ordered"; type: "bullet" | "ordered";
@@ -14,7 +15,6 @@ type LinkState = {
type RenderEnv = { type RenderEnv = {
listStack: ListState[]; listStack: ListState[];
linkStack: LinkState[];
}; };
type MarkdownToken = { type MarkdownToken = {
@@ -50,15 +50,36 @@ type OpenStyle = {
start: number; start: number;
}; };
type RenderState = { type RenderTarget = {
text: string; text: string;
styles: MarkdownStyleSpan[]; styles: MarkdownStyleSpan[];
openStyles: OpenStyle[]; openStyles: OpenStyle[];
links: MarkdownLinkSpan[]; 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; env: RenderEnv;
headingStyle: "none" | "bold"; headingStyle: "none" | "bold";
blockquotePrefix: string; blockquotePrefix: string;
enableSpoilers: boolean; enableSpoilers: boolean;
tableMode: MarkdownTableMode;
table: TableState | null;
hasTables: boolean;
}; };
export type MarkdownParseOptions = { export type MarkdownParseOptions = {
@@ -67,6 +88,8 @@ export type MarkdownParseOptions = {
headingStyle?: "none" | "bold"; headingStyle?: "none" | "bold";
blockquotePrefix?: string; blockquotePrefix?: string;
autolink?: boolean; autolink?: boolean;
/** How to render tables (off|bullets|code). Default: off. */
tableMode?: MarkdownTableMode;
}; };
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
@@ -77,6 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
typographer: false, typographer: false,
}); });
md.enable("strikethrough"); md.enable("strikethrough");
if (options.tableMode && options.tableMode !== "off") {
md.enable("table");
} else {
md.disable("table");
}
if (options.autolink === false) { if (options.autolink === false) {
md.disable("autolink"); md.disable("autolink");
} }
@@ -144,23 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
return result; 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) { function appendText(state: RenderState, value: string) {
if (!value) return; if (!value) return;
state.text += value; const target = resolveRenderTarget(state);
target.text += value;
} }
function openStyle(state: RenderState, style: MarkdownStyle) { 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) { function closeStyle(state: RenderState, style: MarkdownStyle) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { const target = resolveRenderTarget(state);
if (state.openStyles[i]?.style === style) { for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
const start = state.openStyles[i].start; if (target.openStyles[i]?.style === style) {
state.openStyles.splice(i, 1); const start = target.openStyles[i].start;
const end = state.text.length; target.openStyles.splice(i, 1);
const end = target.text.length;
if (end > start) { if (end > start) {
state.styles.push({ start, end, style }); target.styles.push({ start, end, style });
} }
return; return;
} }
@@ -169,7 +214,8 @@ function closeStyle(state: RenderState, style: MarkdownStyle) {
function appendParagraphSeparator(state: RenderState) { function appendParagraphSeparator(state: RenderState) {
if (state.env.listStack.length > 0) return; if (state.env.listStack.length > 0) return;
appendText(state, "\n\n"); if (state.table) return; // Don't add paragraph separators inside tables
state.text += "\n\n";
} }
function appendListPrefix(state: RenderState) { function appendListPrefix(state: RenderState) {
@@ -179,39 +225,232 @@ function appendListPrefix(state: RenderState) {
top.index += 1; top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1)); const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
appendText(state, `${indent}${prefix}`); state.text += `${indent}${prefix}`;
} }
function renderInlineCode(state: RenderState, content: string) { function renderInlineCode(state: RenderState, content: string) {
if (!content) return; if (!content) return;
const start = state.text.length; const target = resolveRenderTarget(state);
appendText(state, content); const start = target.text.length;
state.styles.push({ start, end: start + content.length, style: "code" }); target.text += content;
target.styles.push({ start, end: start + content.length, style: "code" });
} }
function renderCodeBlock(state: RenderState, content: string) { function renderCodeBlock(state: RenderState, content: string) {
let code = content ?? ""; let code = content ?? "";
if (!code.endsWith("\n")) code = `${code}\n`; if (!code.endsWith("\n")) code = `${code}\n`;
const start = state.text.length; const target = resolveRenderTarget(state);
appendText(state, code); const start = target.text.length;
state.styles.push({ start, end: start + code.length, style: "code_block" }); target.text += code;
target.styles.push({ start, end: start + code.length, style: "code_block" });
if (state.env.listStack.length === 0) { if (state.env.listStack.length === 0) {
appendText(state, "\n"); target.text += "\n";
} }
} }
function handleLinkClose(state: RenderState) { function handleLinkClose(state: RenderState) {
const link = state.env.linkStack.pop(); const target = resolveRenderTarget(state);
const link = target.linkStack.pop();
if (!link?.href) return; if (!link?.href) return;
const href = link.href.trim(); const href = link.href.trim();
if (!href) return; if (!href) return;
const start = link.labelStart; const start = link.labelStart;
const end = state.text.length; const end = target.text.length;
if (end <= start) { if (end <= start) {
state.links.push({ start, end, href }); target.links.push({ start, end, href });
return; return;
} }
state.links.push({ start, end, href }); target.links.push({ start, end, href });
}
function initTableState(): TableState {
return {
headers: [],
rows: [],
currentRow: [],
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 = 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;
// Determine if first column should be used as row labels
// (common pattern: first column is category/feature name)
const useFirstColAsLabel = headers.length > 1 && rows.length > 0;
if (useFirstColAsLabel) {
// Format: each row becomes a section with header as row[0], then key:value pairs
for (const row of rows) {
if (row.length === 0) continue;
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];
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";
}
} else {
// Simple table: just list headers and values
for (const row of rows) {
for (let i = 0; i < row.length; i++) {
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 renderTableAsCode(state: RenderState) {
if (!state.table) return;
const headers = state.table.headers.map(trimCell);
const rows = state.table.rows.map((row) => row.map(trimCell));
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
if (columnCount === 0) return;
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;
}
};
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";
}
} }
function renderTokens(tokens: MarkdownToken[], state: RenderState): void { function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
@@ -252,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
break; break;
case "link_open": { case "link_open": {
const href = getAttr(token, "href") ?? ""; 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; break;
} }
case "link_close": case "link_close":
@@ -276,10 +516,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
appendParagraphSeparator(state); appendParagraphSeparator(state);
break; break;
case "blockquote_open": case "blockquote_open":
if (state.blockquotePrefix) appendText(state, state.blockquotePrefix); if (state.blockquotePrefix) state.text += state.blockquotePrefix;
break; break;
case "blockquote_close": case "blockquote_close":
appendText(state, "\n"); state.text += "\n";
break; break;
case "bullet_list_open": case "bullet_list_open":
state.env.listStack.push({ type: "bullet", index: 0 }); state.env.listStack.push({ type: "bullet", index: 0 });
@@ -299,7 +539,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
appendListPrefix(state); appendListPrefix(state);
break; break;
case "list_item_close": case "list_item_close":
appendText(state, "\n"); state.text += "\n";
break; break;
case "code_block": case "code_block":
case "fence": case "fence":
@@ -309,22 +549,68 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "html_inline": case "html_inline":
appendText(state, token.content ?? ""); appendText(state, token.content ?? "");
break; break;
// Table handling
case "table_open": case "table_open":
if (state.tableMode !== "off") {
state.table = initTableState();
state.hasTables = true;
}
break;
case "table_close": case "table_close":
if (state.table) {
if (state.tableMode === "bullets") {
renderTableAsBullets(state);
} else if (state.tableMode === "code") {
renderTableAsCode(state);
}
}
state.table = null;
break;
case "thead_open": case "thead_open":
if (state.table) {
state.table.inHeader = true;
}
break;
case "thead_close": case "thead_close":
if (state.table) {
state.table.inHeader = false;
}
break;
case "tbody_open": case "tbody_open":
case "tbody_close": case "tbody_close":
break; break;
case "tr_open":
if (state.table) {
state.table.currentRow = [];
}
break;
case "tr_close": case "tr_close":
appendText(state, "\n"); if (state.table) {
if (state.table.inHeader) {
state.table.headers = state.table.currentRow;
} else {
state.table.rows.push(state.table.currentRow);
}
state.table.currentRow = [];
}
break;
case "th_open":
case "td_open":
if (state.table) {
state.table.currentCell = initRenderTarget();
}
break; break;
case "th_close": case "th_close":
case "td_close": case "td_close":
appendText(state, "\t"); if (state.table?.currentCell) {
state.table.currentRow.push(finishTableCell(state.table.currentCell));
state.table.currentCell = null;
}
break; break;
case "hr": case "hr":
appendText(state, "\n"); state.text += "\n";
break; break;
default: default:
if (token.children) renderTokens(token.children, state); if (token.children) renderTokens(token.children, state);
@@ -333,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
} }
} }
function closeRemainingStyles(state: RenderState) { function closeRemainingStyles(target: RenderTarget) {
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
const open = state.openStyles[i]; const open = target.openStyles[i];
const end = state.text.length; const end = target.text.length;
if (end > open.start) { if (end > open.start) {
state.styles.push({ target.styles.push({
start: open.start, start: open.start,
end, end,
style: open.style, style: open.style,
}); });
} }
} }
state.openStyles = []; target.openStyles = [];
} }
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] { function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
@@ -426,22 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
} }
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR { 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 md = createMarkdownIt(options);
const tokens = md.parse(markdown ?? "", env as unknown as object); const tokens = md.parse(markdown ?? "", env as unknown as object);
if (options.enableSpoilers) { if (options.enableSpoilers) {
applySpoilerTokens(tokens as MarkdownToken[]); applySpoilerTokens(tokens as MarkdownToken[]);
} }
const tableMode = options.tableMode ?? "off";
const state: RenderState = { const state: RenderState = {
text: "", text: "",
styles: [], styles: [],
openStyles: [], openStyles: [],
links: [], links: [],
linkStack: [],
env, env,
headingStyle: options.headingStyle ?? "none", headingStyle: options.headingStyle ?? "none",
blockquotePrefix: options.blockquotePrefix ?? "", blockquotePrefix: options.blockquotePrefix ?? "",
enableSpoilers: options.enableSpoilers ?? false, enableSpoilers: options.enableSpoilers ?? false,
tableMode,
table: null,
hasTables: false,
}; };
renderTokens(tokens as MarkdownToken[], state); renderTokens(tokens as MarkdownToken[], state);
@@ -459,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength); finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
return { return {
text: finalText, ir: {
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), text: finalText,
links: clampLinkSpans(state.links, finalLength), styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
links: clampLinkSpans(state.links, finalLength),
},
hasTables: state.hasTables,
}; };
} }
+34
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})` };
},
});
}
+4
View File
@@ -73,6 +73,8 @@ export type {
DmPolicy, DmPolicy,
DmConfig, DmConfig,
GroupPolicy, GroupPolicy,
MarkdownConfig,
MarkdownTableMode,
MSTeamsChannelConfig, MSTeamsChannelConfig,
MSTeamsConfig, MSTeamsConfig,
MSTeamsReplyStyle, MSTeamsReplyStyle,
@@ -92,6 +94,8 @@ export {
DmConfigSchema, DmConfigSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
MarkdownTableModeSchema,
normalizeAllowFrom, normalizeAllowFrom,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "../config/zod-schema.core.js"; } from "../config/zod-schema.core.js";
+4
View File
@@ -34,6 +34,7 @@ import {
resolveChannelGroupPolicy, resolveChannelGroupPolicy,
resolveChannelGroupRequireMention, resolveChannelGroupRequireMention,
} from "../../config/group-policy.js"; } from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { resolveStateDir } from "../../config/paths.js"; import { resolveStateDir } from "../../config/paths.js";
import { loadConfig, writeConfigFile } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js";
import { import {
@@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js"; import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js"; import { sendMessageIMessage } from "../../imessage/send.js";
import { shouldLogVerbose } from "../../globals.js"; import { shouldLogVerbose } from "../../globals.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { getChildLogger } from "../../logging.js"; import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js"; import { normalizeLogLevel } from "../../logging/levels.js";
import { isVoiceCompatibleAudio } from "../../media/audio.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js";
@@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime {
chunkText, chunkText,
resolveTextChunkLimit, resolveTextChunkLimit,
hasControlCommand, hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
}, },
reply: { reply: {
dispatchReplyWithBufferedBlockDispatcher, dispatchReplyWithBufferedBlockDispatcher,
+5
View File
@@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers =
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; 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 HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand;
type IsControlCommandMessage = type IsControlCommandMessage =
typeof import("../../auto-reply/command-detection.js").isControlCommandMessage; typeof import("../../auto-reply/command-detection.js").isControlCommandMessage;
@@ -168,6 +171,8 @@ export type PluginRuntime = {
chunkText: ChunkText; chunkText: ChunkText;
resolveTextChunkLimit: ResolveTextChunkLimit; resolveTextChunkLimit: ResolveTextChunkLimit;
hasControlCommand: HasControlCommand; hasControlCommand: HasControlCommand;
resolveMarkdownTableMode: ResolveMarkdownTableMode;
convertMarkdownTables: ConvertMarkdownTables;
}; };
reply: { reply: {
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
+16 -2
View File
@@ -4,6 +4,7 @@ import {
type MarkdownIR, type MarkdownIR,
type MarkdownStyle, type MarkdownStyle,
} from "../markdown/ir.js"; } from "../markdown/ir.js";
import type { MarkdownTableMode } from "../config/types.base.js";
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER";
@@ -18,6 +19,10 @@ export type SignalFormattedText = {
styles: SignalTextStyleRange[]; styles: SignalTextStyleRange[];
}; };
type SignalMarkdownOptions = {
tableMode?: MarkdownTableMode;
};
type SignalStyleSpan = { type SignalStyleSpan = {
start: number; start: number;
end: 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 ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: true, linkify: true,
enableSpoilers: true, enableSpoilers: true,
headingStyle: "none", headingStyle: "none",
blockquotePrefix: "", blockquotePrefix: "",
tableMode: options.tableMode,
}); });
return renderSignalText(ir); 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 ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: true, linkify: true,
enableSpoilers: true, enableSpoilers: true,
headingStyle: "none", headingStyle: "none",
blockquotePrefix: "", blockquotePrefix: "",
tableMode: options.tableMode,
}); });
const chunks = chunkMarkdownIR(ir, limit); const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => renderSignalText(chunk)); return chunks.map((chunk) => renderSignalText(chunk));
+7 -1
View File
@@ -1,4 +1,5 @@
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
@@ -164,7 +165,12 @@ export async function sendMessageSignal(
if (textMode === "plain") { if (textMode === "plain") {
textStyles = opts.textStyles ?? []; textStyles = opts.textStyles ?? [];
} else { } else {
const formatted = markdownToSignalText(message); const tableMode = resolveMarkdownTableMode({
cfg,
channel: "signal",
accountId: accountInfo.accountId,
});
const formatted = markdownToSignalText(message, { tableMode });
message = formatted.text; message = formatted.text;
textStyles = formatted.styles; textStyles = formatted.styles;
} }
+16 -2
View File
@@ -1,4 +1,5 @@
import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js";
import type { MarkdownTableMode } from "../config/types.base.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js";
// Escape special characters for Slack mrkdwn format. // 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 ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: false, linkify: false,
autolink: false, autolink: false,
headingStyle: "bold", headingStyle: "bold",
blockquotePrefix: "> ", blockquotePrefix: "> ",
tableMode: options.tableMode,
}); });
return renderMarkdownWithMarkers(ir, { return renderMarkdownWithMarkers(ir, {
styleMarkers: { 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 ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: false, linkify: false,
autolink: false, autolink: false,
headingStyle: "bold", headingStyle: "bold",
blockquotePrefix: "> ", blockquotePrefix: "> ",
tableMode: options.tableMode,
}); });
const chunks = chunkMarkdownIR(ir, limit); const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => return chunks.map((chunk) =>
+5 -1
View File
@@ -1,6 +1,7 @@
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { markdownToSlackMrkdwnChunks } from "../format.js"; import { markdownToSlackMrkdwnChunks } from "../format.js";
import { sendMessageSlack } from "../send.js"; import { sendMessageSlack } from "../send.js";
@@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: {
respond: SlackRespondFn; respond: SlackRespondFn;
ephemeral: boolean; ephemeral: boolean;
textLimit: number; textLimit: number;
tableMode?: MarkdownTableMode;
}) { }) {
const messages: string[] = []; const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
@@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
if (!combined) continue; if (!combined) continue;
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) { for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
tableMode: params.tableMode,
})) {
messages.push(chunk); messages.push(chunk);
} }
} }
+11
View File
@@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose } from "../../globals.js"; import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { import {
@@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: {
respond, respond,
ephemeral: slashCommand.ephemeral, ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit, textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: {
respond, respond,
ephemeral: slashCommand.ephemeral, ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit, textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
}); });
} }
} catch (err) { } catch (err) {
+7 -1
View File
@@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js"; import { createSlackWebClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js"; import { markdownToSlackMrkdwnChunks } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { parseSlackTarget } from "./targets.js"; import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js"; import { resolveSlackBotToken } from "./token.js";
@@ -143,7 +144,12 @@ export async function sendMessageSlack(
const { channelId } = await resolveChannelId(client, recipient); const { channelId } = await resolveChannelId(client, recipient);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); 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 = const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number" typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024 ? account.config.mediaMaxMb * 1024 * 1024
+7
View File
@@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { clearHistoryEntries } from "../auto-reply/reply/history.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { danger, logVerbose } from "../globals.js"; import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { deliverReplies } from "./bot/delivery.js"; import { deliverReplies } from "./bot/delivery.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js"; import { createTelegramDraftStream } from "./draft-stream.js";
@@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({
let prefixContext: ResponsePrefixContext = { let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId), identityName: resolveIdentityName(cfg, route.agentId),
}; };
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
@@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId: resolvedThreadId, messageThreadId: resolvedThreadId,
tableMode,
onVoiceRecording: sendRecordVoice, onVoiceRecording: sendRecordVoice,
}); });
}, },
+7
View File
@@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js"; import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js";
@@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
}, },
}); });
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [ const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null, groupConfig?.systemPrompt?.trim() || null,
@@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId: resolvedThreadId, messageThreadId: resolvedThreadId,
tableMode,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
+8 -2
View File
@@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
import { splitTelegramCaption } from "../caption.js"; import { splitTelegramCaption } from "../caption.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js"; import type { ReplyToMode } from "../../config/config.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { danger, logVerbose } from "../../globals.js"; import { danger, logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js"; import { formatErrorMessage } from "../../infra/errors.js";
import { mediaKindFromMime } from "../../media/constants.js"; import { mediaKindFromMime } from "../../media/constants.js";
@@ -26,6 +27,7 @@ export async function deliverReplies(params: {
replyToMode: ReplyToMode; replyToMode: ReplyToMode;
textLimit: number; textLimit: number;
messageThreadId?: number; messageThreadId?: number;
tableMode?: MarkdownTableMode;
/** Callback invoked before sending a voice message to switch typing indicator. */ /** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void; onVoiceRecording?: () => Promise<void> | void;
}) { }) {
@@ -49,7 +51,9 @@ export async function deliverReplies(params: {
? [reply.mediaUrl] ? [reply.mediaUrl]
: []; : [];
if (mediaList.length === 0) { if (mediaList.length === 0) {
const chunks = markdownToTelegramChunks(reply.text || "", textLimit); const chunks = markdownToTelegramChunks(reply.text || "", textLimit, {
tableMode: params.tableMode,
});
for (const chunk of chunks) { for (const chunk of chunks) {
await sendTelegramText(bot, chatId, chunk.html, runtime, { await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId: replyToMessageId:
@@ -139,7 +143,9 @@ export async function deliverReplies(params: {
// Send deferred follow-up text right after the first media item. // 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). // Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) { if (pendingFollowUpText && isFirstMedia) {
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, {
tableMode: params.tableMode,
});
for (const chunk of chunks) { for (const chunk of chunks) {
const replyToMessageIdFollowup = const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
+8 -1
View File
@@ -5,6 +5,7 @@ import {
type MarkdownIR, type MarkdownIR,
} from "../markdown/ir.js"; } from "../markdown/ir.js";
import { renderMarkdownWithMarkers } from "../markdown/render.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js";
import type { MarkdownTableMode } from "../config/types.base.js";
export type TelegramFormattedChunk = { export type TelegramFormattedChunk = {
html: string; html: string;
@@ -46,11 +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 ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: true, linkify: true,
headingStyle: "none", headingStyle: "none",
blockquotePrefix: "", blockquotePrefix: "",
tableMode: options.tableMode,
}); });
return renderTelegramHtml(ir); return renderTelegramHtml(ir);
} }
@@ -58,11 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string {
export function markdownToTelegramChunks( export function markdownToTelegramChunks(
markdown: string, markdown: string,
limit: number, limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): TelegramFormattedChunk[] { ): TelegramFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", { const ir = markdownToIR(markdown ?? "", {
linkify: true, linkify: true,
headingStyle: "none", headingStyle: "none",
blockquotePrefix: "", blockquotePrefix: "",
tableMode: options.tableMode,
}); });
const chunks = chunkMarkdownIR(ir, limit); const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => ({ return chunks.map((chunk) => ({
+7 -1
View File
@@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js"; import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js"; import { markdownToTelegramHtml } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { splitTelegramCaption } from "./caption.js"; import { splitTelegramCaption } from "./caption.js";
import { recordSentMessage } from "./sent-message-cache.js"; import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.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"); throw new Error("Message must be non-empty for Telegram sends");
} }
const textMode = opts.textMode ?? "markdown"; 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 const textParams = hasThreadParams
? { ? {
parse_mode: "HTML" as const, parse_mode: "HTML" as const,
+6 -1
View File
@@ -1,4 +1,6 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js"; 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 type { ReplyPayload } from "../../auto-reply/types.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { loadWebMedia } from "../media.js"; import { loadWebMedia } from "../media.js";
@@ -19,10 +21,13 @@ export async function deliverWebReply(params: {
}; };
connectionId?: string; connectionId?: string;
skipLog?: boolean; skipLog?: boolean;
tableMode?: MarkdownTableMode;
}) { }) {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const replyStarted = Date.now(); 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 const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls ? replyResult.mediaUrls
: replyResult.mediaUrl : replyResult.mediaUrl
@@ -28,6 +28,7 @@ import {
recordSessionMetaFromInbound, recordSessionMetaFromInbound,
resolveStorePath, resolveStorePath,
} from "../../../config/sessions.js"; } from "../../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js"; import type { getChildLogger } from "../../../logging.js";
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
@@ -235,6 +236,11 @@ export async function processMessage(params: {
: undefined; : undefined;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
});
let didLogHeartbeatStrip = false; let didLogHeartbeatStrip = false;
let didSendReply = false; let didSendReply = false;
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg) const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
@@ -345,6 +351,7 @@ export async function processMessage(params: {
connectionId: params.connectionId, connectionId: params.connectionId,
// Tool + block updates are noisy; skip their log lines. // Tool + block updates are noisy; skip their log lines.
skipLog: info.kind !== "final", skipLog: info.kind !== "final",
tableMode,
}); });
didSendReply = true; didSendReply = true;
if (info.kind === "tool") { if (info.kind === "tool") {
+10
View File
@@ -4,6 +4,9 @@ import { getChildLogger } from "../logging/logger.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePollInput, type PollInput } from "../polls.js"; import { normalizePollInput, type PollInput } from "../polls.js";
import { toWhatsappJid } from "../utils.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 { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
import { loadWebMedia } from "./media.js"; import { loadWebMedia } from "./media.js";
@@ -25,6 +28,13 @@ export async function sendMessageWhatsApp(
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
options.accountId, options.accountId,
); );
const cfg = loadConfig();
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "whatsapp",
accountId: resolvedAccountId ?? options.accountId,
});
text = convertMarkdownTables(text ?? "", tableMode);
const logger = getChildLogger({ const logger = getChildLogger({
module: "web-outbound", module: "web-outbound",
correlationId, correlationId,