mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0)
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
60
src/config/markdown-tables.ts
Normal file
60
src/config/markdown-tables.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,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). */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("markdownToIR tableMode bullets", () => {
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
// Should contain bullet points with header:value format
|
// Should contain bullet points with header:value format
|
||||||
expect(ir.text).toContain("• Value: 1");
|
expect(ir.text).toContain("• Value: 1");
|
||||||
expect(ir.text).toContain("• Value: 2");
|
expect(ir.text).toContain("• Value: 2");
|
||||||
@@ -29,7 +29,7 @@ describe("markdownToIR tableMode bullets", () => {
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
// First column becomes row label
|
// First column becomes row label
|
||||||
expect(ir.text).toContain("Speed");
|
expect(ir.text).toContain("Speed");
|
||||||
expect(ir.text).toContain("Scale");
|
expect(ir.text).toContain("Scale");
|
||||||
@@ -40,22 +40,20 @@ describe("markdownToIR tableMode bullets", () => {
|
|||||||
expect(ir.text).toContain("• Postgres: Large");
|
expect(ir.text).toContain("• Postgres: Large");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves flat mode as default", () => {
|
it("leaves table syntax untouched by default", () => {
|
||||||
const md = `
|
const md = `
|
||||||
| A | B |
|
| A | B |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1 | 2 |
|
| 1 | 2 |
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const ir = markdownToIR(md); // default is flat
|
const ir = markdownToIR(md);
|
||||||
|
|
||||||
// Flat mode uses tabs
|
// No table conversion by default
|
||||||
expect(ir.text).toContain("A");
|
expect(ir.text).toContain("| A | B |");
|
||||||
expect(ir.text).toContain("B");
|
expect(ir.text).toContain("| 1 | 2 |");
|
||||||
expect(ir.text).toContain("1");
|
|
||||||
expect(ir.text).toContain("2");
|
|
||||||
// Should not have bullet formatting
|
|
||||||
expect(ir.text).not.toContain("•");
|
expect(ir.text).not.toContain("•");
|
||||||
|
expect(ir.styles.some((style) => style.style === "code_block")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty cells gracefully", () => {
|
it("handles empty cells gracefully", () => {
|
||||||
@@ -67,7 +65,7 @@ describe("markdownToIR tableMode bullets", () => {
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
// Should handle empty cell without crashing
|
// Should handle empty cell without crashing
|
||||||
expect(ir.text).toContain("B");
|
expect(ir.text).toContain("B");
|
||||||
expect(ir.text).toContain("• Value: 2");
|
expect(ir.text).toContain("• Value: 2");
|
||||||
@@ -81,11 +79,41 @@ describe("markdownToIR tableMode bullets", () => {
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
// Should have bold style for row label
|
// Should have bold style for row label
|
||||||
const hasRowLabelBold = ir.styles.some(
|
const hasRowLabelBold = ir.styles.some(
|
||||||
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1"
|
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1",
|
||||||
);
|
);
|
||||||
expect(hasRowLabelBold).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -12,24 +13,8 @@ type LinkState = {
|
|||||||
labelStart: number;
|
labelStart: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableCell = {
|
|
||||||
content: string;
|
|
||||||
isHeader: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableRow = TableCell[];
|
|
||||||
|
|
||||||
type TableState = {
|
|
||||||
headers: string[];
|
|
||||||
rows: TableRow[];
|
|
||||||
currentRow: TableCell[];
|
|
||||||
currentCell: string;
|
|
||||||
inHeader: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RenderEnv = {
|
type RenderEnv = {
|
||||||
listStack: ListState[];
|
listStack: ListState[];
|
||||||
linkStack: LinkState[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type MarkdownToken = {
|
type MarkdownToken = {
|
||||||
@@ -65,19 +50,36 @@ type OpenStyle = {
|
|||||||
start: number;
|
start: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TableRenderMode = "flat" | "bullets";
|
type RenderTarget = {
|
||||||
|
|
||||||
type RenderState = {
|
|
||||||
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: TableRenderMode;
|
tableMode: MarkdownTableMode;
|
||||||
table: TableState | null;
|
table: TableState | null;
|
||||||
|
hasTables: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarkdownParseOptions = {
|
export type MarkdownParseOptions = {
|
||||||
@@ -86,8 +88,8 @@ export type MarkdownParseOptions = {
|
|||||||
headingStyle?: "none" | "bold";
|
headingStyle?: "none" | "bold";
|
||||||
blockquotePrefix?: string;
|
blockquotePrefix?: string;
|
||||||
autolink?: boolean;
|
autolink?: boolean;
|
||||||
/** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */
|
/** How to render tables (off|bullets|code). Default: off. */
|
||||||
tableMode?: TableRenderMode;
|
tableMode?: MarkdownTableMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||||
@@ -98,7 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
|||||||
typographer: false,
|
typographer: false,
|
||||||
});
|
});
|
||||||
md.enable("strikethrough");
|
md.enable("strikethrough");
|
||||||
md.enable("table");
|
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");
|
||||||
}
|
}
|
||||||
@@ -166,28 +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;
|
||||||
// If we're inside a table cell in bullets mode, collect into cell buffer
|
const target = resolveRenderTarget(state);
|
||||||
if (state.table && state.tableMode === "bullets") {
|
target.text += value;
|
||||||
state.table.currentCell += value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.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;
|
||||||
}
|
}
|
||||||
@@ -212,39 +230,37 @@ function appendListPrefix(state: RenderState) {
|
|||||||
|
|
||||||
function renderInlineCode(state: RenderState, content: string) {
|
function renderInlineCode(state: RenderState, content: string) {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
// In bullets mode inside table, just add text without styling
|
const target = resolveRenderTarget(state);
|
||||||
if (state.table && state.tableMode === "bullets") {
|
const start = target.text.length;
|
||||||
state.table.currentCell += content;
|
target.text += content;
|
||||||
return;
|
target.styles.push({ start, end: start + content.length, style: "code" });
|
||||||
}
|
|
||||||
const start = state.text.length;
|
|
||||||
state.text += content;
|
|
||||||
state.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);
|
||||||
state.text += 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) {
|
||||||
state.text += "\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 {
|
function initTableState(): TableState {
|
||||||
@@ -252,14 +268,72 @@ function initTableState(): TableState {
|
|||||||
headers: [],
|
headers: [],
|
||||||
rows: [],
|
rows: [],
|
||||||
currentRow: [],
|
currentRow: [],
|
||||||
currentCell: "",
|
currentCell: null,
|
||||||
inHeader: false,
|
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) {
|
function renderTableAsBullets(state: RenderState) {
|
||||||
if (!state.table) return;
|
if (!state.table) return;
|
||||||
const { headers, rows } = state.table;
|
const headers = state.table.headers.map(trimCell);
|
||||||
|
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||||
|
|
||||||
// If no headers or rows, skip
|
// If no headers or rows, skip
|
||||||
if (headers.length === 0 && rows.length === 0) return;
|
if (headers.length === 0 && rows.length === 0) return;
|
||||||
@@ -273,22 +347,31 @@ function renderTableAsBullets(state: RenderState) {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.length === 0) continue;
|
if (row.length === 0) continue;
|
||||||
|
|
||||||
const rowLabel = row[0]?.content?.trim() || "";
|
const rowLabel = row[0];
|
||||||
if (rowLabel) {
|
if (rowLabel?.text) {
|
||||||
// Bold the row label
|
const labelStart = state.text.length;
|
||||||
const start = state.text.length;
|
appendCell(state, rowLabel);
|
||||||
state.text += rowLabel;
|
const labelEnd = state.text.length;
|
||||||
state.styles.push({ start, end: state.text.length, style: "bold" });
|
if (labelEnd > labelStart) {
|
||||||
|
state.styles.push({ start: labelStart, end: labelEnd, style: "bold" });
|
||||||
|
}
|
||||||
state.text += "\n";
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add each column as a bullet point
|
// Add each column as a bullet point
|
||||||
for (let i = 1; i < row.length; i++) {
|
for (let i = 1; i < row.length; i++) {
|
||||||
const header = headers[i]?.trim() || `Column ${i}`;
|
const header = headers[i];
|
||||||
const value = row[i]?.content?.trim() || "";
|
const value = row[i];
|
||||||
if (value) {
|
if (!value?.text) continue;
|
||||||
state.text += `• ${header}: ${value}\n`;
|
state.text += "• ";
|
||||||
|
if (header?.text) {
|
||||||
|
appendCell(state, header);
|
||||||
|
state.text += ": ";
|
||||||
|
} else {
|
||||||
|
state.text += `Column ${i}: `;
|
||||||
}
|
}
|
||||||
|
appendCell(state, value);
|
||||||
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
state.text += "\n";
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
@@ -296,37 +379,77 @@ function renderTableAsBullets(state: RenderState) {
|
|||||||
// Simple table: just list headers and values
|
// Simple table: just list headers and values
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
for (let i = 0; i < row.length; i++) {
|
for (let i = 0; i < row.length; i++) {
|
||||||
const header = headers[i]?.trim() || "";
|
const header = headers[i];
|
||||||
const value = row[i]?.content?.trim() || "";
|
const value = row[i];
|
||||||
if (header && value) {
|
if (!value?.text) continue;
|
||||||
state.text += `• ${header}: ${value}\n`;
|
state.text += "• ";
|
||||||
} else if (value) {
|
if (header?.text) {
|
||||||
state.text += `• ${value}\n`;
|
appendCell(state, header);
|
||||||
|
state.text += ": ";
|
||||||
}
|
}
|
||||||
|
appendCell(state, value);
|
||||||
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
state.text += "\n";
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTableAsFlat(state: RenderState) {
|
function renderTableAsCode(state: RenderState) {
|
||||||
if (!state.table) return;
|
if (!state.table) return;
|
||||||
const { headers, rows } = state.table;
|
const headers = state.table.headers.map(trimCell);
|
||||||
|
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||||
|
|
||||||
// Render headers
|
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
|
||||||
for (const header of headers) {
|
if (columnCount === 0) return;
|
||||||
state.text += header.trim() + "\t";
|
|
||||||
}
|
|
||||||
if (headers.length > 0) {
|
|
||||||
state.text = state.text.trimEnd() + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render rows
|
const widths = Array.from({ length: columnCount }, () => 0);
|
||||||
for (const row of rows) {
|
const updateWidths = (cells: TableCell[]) => {
|
||||||
for (const cell of row) {
|
for (let i = 0; i < columnCount; i += 1) {
|
||||||
state.text += cell.content.trim() + "\t";
|
const cell = cells[i];
|
||||||
|
const width = cell?.text.length ?? 0;
|
||||||
|
if (widths[i] < width) widths[i] = width;
|
||||||
}
|
}
|
||||||
state.text = state.text.trimEnd() + "\n";
|
};
|
||||||
|
updateWidths(headers);
|
||||||
|
for (const row of rows) updateWidths(row);
|
||||||
|
|
||||||
|
const codeStart = state.text.length;
|
||||||
|
|
||||||
|
const appendRow = (cells: TableCell[]) => {
|
||||||
|
state.text += "|";
|
||||||
|
for (let i = 0; i < columnCount; i += 1) {
|
||||||
|
state.text += " ";
|
||||||
|
const cell = cells[i];
|
||||||
|
if (cell) appendCell(state, cell);
|
||||||
|
const pad = widths[i] - (cell?.text.length ?? 0);
|
||||||
|
if (pad > 0) state.text += " ".repeat(pad);
|
||||||
|
state.text += " |";
|
||||||
|
}
|
||||||
|
state.text += "\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendDivider = () => {
|
||||||
|
state.text += "|";
|
||||||
|
for (let i = 0; i < columnCount; i += 1) {
|
||||||
|
const dashCount = Math.max(3, widths[i]);
|
||||||
|
state.text += ` ${"-".repeat(dashCount)} |`;
|
||||||
|
}
|
||||||
|
state.text += "\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
appendRow(headers);
|
||||||
|
appendDivider();
|
||||||
|
for (const row of rows) {
|
||||||
|
appendRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeEnd = state.text.length;
|
||||||
|
if (codeEnd > codeStart) {
|
||||||
|
state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" });
|
||||||
|
}
|
||||||
|
if (state.env.listStack.length === 0) {
|
||||||
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
break;
|
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":
|
||||||
@@ -428,15 +552,18 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
|
|
||||||
// Table handling
|
// Table handling
|
||||||
case "table_open":
|
case "table_open":
|
||||||
if (state.tableMode === "bullets") {
|
if (state.tableMode !== "off") {
|
||||||
state.table = initTableState();
|
state.table = initTableState();
|
||||||
|
state.hasTables = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "table_close":
|
case "table_close":
|
||||||
if (state.tableMode === "bullets" && state.table) {
|
if (state.table) {
|
||||||
renderTableAsBullets(state);
|
if (state.tableMode === "bullets") {
|
||||||
} else if (state.tableMode === "flat" && state.table) {
|
renderTableAsBullets(state);
|
||||||
renderTableAsFlat(state);
|
} else if (state.tableMode === "code") {
|
||||||
|
renderTableAsCode(state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.table = null;
|
state.table = null;
|
||||||
break;
|
break;
|
||||||
@@ -461,33 +588,24 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
case "tr_close":
|
case "tr_close":
|
||||||
if (state.table) {
|
if (state.table) {
|
||||||
if (state.table.inHeader) {
|
if (state.table.inHeader) {
|
||||||
state.table.headers = state.table.currentRow.map((c) => c.content);
|
state.table.headers = state.table.currentRow;
|
||||||
} else {
|
} else {
|
||||||
state.table.rows.push(state.table.currentRow);
|
state.table.rows.push(state.table.currentRow);
|
||||||
}
|
}
|
||||||
state.table.currentRow = [];
|
state.table.currentRow = [];
|
||||||
} else if (state.tableMode === "flat") {
|
|
||||||
// Legacy flat mode without table state
|
|
||||||
state.text += "\n";
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "th_open":
|
case "th_open":
|
||||||
case "td_open":
|
case "td_open":
|
||||||
if (state.table) {
|
if (state.table) {
|
||||||
state.table.currentCell = "";
|
state.table.currentCell = initRenderTarget();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "th_close":
|
case "th_close":
|
||||||
case "td_close":
|
case "td_close":
|
||||||
if (state.table) {
|
if (state.table?.currentCell) {
|
||||||
state.table.currentRow.push({
|
state.table.currentRow.push(finishTableCell(state.table.currentCell));
|
||||||
content: state.table.currentCell,
|
state.table.currentCell = null;
|
||||||
isHeader: token.type === "th_close",
|
|
||||||
});
|
|
||||||
state.table.currentCell = "";
|
|
||||||
} else if (state.tableMode === "flat") {
|
|
||||||
// Legacy flat mode without table state
|
|
||||||
state.text += "\t";
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -501,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[] {
|
||||||
@@ -594,26 +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 ?? "flat";
|
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,
|
tableMode,
|
||||||
table: null,
|
table: null,
|
||||||
|
hasTables: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
renderTokens(tokens as MarkdownToken[], state);
|
renderTokens(tokens as MarkdownToken[], state);
|
||||||
@@ -631,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
|
|||||||
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
|
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
src/markdown/tables.ts
Normal file
34
src/markdown/tables.ts
Normal 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})` };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,12 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markdownToTelegramHtml(markdown: string): string {
|
export function markdownToTelegramHtml(
|
||||||
|
markdown: string,
|
||||||
|
options: { tableMode?: MarkdownTableMode } = {},
|
||||||
|
): string {
|
||||||
const ir = markdownToIR(markdown ?? "", {
|
const ir = markdownToIR(markdown ?? "", {
|
||||||
linkify: true,
|
linkify: true,
|
||||||
headingStyle: "none",
|
headingStyle: "none",
|
||||||
blockquotePrefix: "",
|
blockquotePrefix: "",
|
||||||
tableMode: "bullets",
|
tableMode: options.tableMode,
|
||||||
});
|
});
|
||||||
return renderTelegramHtml(ir);
|
return renderTelegramHtml(ir);
|
||||||
}
|
}
|
||||||
@@ -59,12 +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: "bullets",
|
tableMode: options.tableMode,
|
||||||
});
|
});
|
||||||
const chunks = chunkMarkdownIR(ir, limit);
|
const chunks = chunkMarkdownIR(ir, limit);
|
||||||
return chunks.map((chunk) => ({
|
return chunks.map((chunk) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user