fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0)
This commit is contained in:
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";
|
||||
};
|
||||
|
||||
export type MarkdownTableMode = "off" | "bullets" | "code";
|
||||
|
||||
export type MarkdownConfig = {
|
||||
/** Table rendering mode (off|bullets|code). */
|
||||
tables?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
export type HumanDelayConfig = {
|
||||
/** Delay style for block replies (off|natural|custom). */
|
||||
mode?: "off" | "natural" | "custom";
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
OutboundRetryConfig,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
@@ -70,6 +71,8 @@ export type DiscordAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Discord (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type IMessageAccountConfig = {
|
||||
@@ -6,6 +11,8 @@ export type IMessageAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this iMessage account. Default: true. */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type MSTeamsWebhookConfig = {
|
||||
@@ -34,6 +39,8 @@ export type MSTeamsConfig = {
|
||||
enabled?: boolean;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** Azure Bot App ID (from Azure Bot registration). */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
@@ -8,6 +13,8 @@ export type SignalAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this Signal account. Default: true. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
@@ -80,6 +81,8 @@ export type SlackAccountConfig = {
|
||||
webhookPath?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Slack (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
OutboundRetryConfig,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
@@ -35,6 +36,8 @@ export type TelegramAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: TelegramCapabilitiesConfig;
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Telegram (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** Custom commands to register in Telegram's command menu (merged with native). */
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type WhatsAppActionConfig = {
|
||||
@@ -12,6 +17,8 @@ export type WhatsAppConfig = {
|
||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** Send read receipts for incoming messages (default true). */
|
||||
@@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||
|
||||
@@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
|
||||
|
||||
export const MarkdownConfigSchema = z
|
||||
.object({
|
||||
tables: MarkdownTableModeSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const HumanDelaySchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DmPolicySchema,
|
||||
ExecutableTokenSchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
MSTeamsReplyStyleSchema,
|
||||
ProviderCommandsSchema,
|
||||
ReplyToModeSchema,
|
||||
@@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: TelegramCapabilitiesSchema.optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||
@@ -193,6 +195,7 @@ export const DiscordAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
@@ -296,6 +299,7 @@ export const SlackAccountSchema = z
|
||||
signingSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
@@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
@@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
cliPath: ExecutableTokenSchema.optional(),
|
||||
@@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
serverUrl: z.string().optional(),
|
||||
@@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
appId: z.string().optional(),
|
||||
appPassword: z.string().optional(),
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export const WhatsAppAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
@@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z
|
||||
.object({
|
||||
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolveStorePath,
|
||||
updateLastRoute,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
||||
@@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, route.agentId),
|
||||
};
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
@@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
replyToId,
|
||||
textLimit,
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
tableMode,
|
||||
});
|
||||
replyReference.markSent();
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordText } from "../chunk.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
@@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: {
|
||||
textLimit: number;
|
||||
maxLinesPerMessage?: number;
|
||||
replyToId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
for (const payload of params.replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const rawText = payload.text ?? "";
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = convertMarkdownTables(rawText, tableMode);
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
const replyTo = params.replyToId?.trim() || undefined;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import type { PollInput } from "../polls.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
@@ -38,6 +40,12 @@ export async function sendMessageDiscord(
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
@@ -47,7 +55,7 @@ export async function sendMessageDiscord(
|
||||
result = await sendDiscordMedia(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
textWithTables,
|
||||
opts.mediaUrl,
|
||||
opts.replyTo,
|
||||
request,
|
||||
@@ -58,7 +66,7 @@ export async function sendMessageDiscord(
|
||||
result = await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
text,
|
||||
textWithTables,
|
||||
opts.replyTo,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { createIMessageRpcClient } from "../client.js";
|
||||
@@ -14,9 +17,16 @@ export async function deliverReplies(params: {
|
||||
textLimit: number;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
|
||||
const cfg = loadConfig();
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
});
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const rawText = payload.text ?? "";
|
||||
const text = convertMarkdownTables(rawText, tableMode);
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, textLimit)) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||
@@ -88,6 +90,14 @@ export async function sendMessageIMessage(
|
||||
if (!message.trim() && !filePath) {
|
||||
throw new Error("iMessage send requires text or media");
|
||||
}
|
||||
if (message.trim()) {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
message = convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits
|
||||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import type { sendMessageDiscord } from "../../discord/send.js";
|
||||
import type { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
||||
@@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: {
|
||||
})
|
||||
: undefined;
|
||||
const isSignalChannel = channel === "signal";
|
||||
const signalTableMode = isSignalChannel
|
||||
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
||||
: "code";
|
||||
const signalMaxBytes = isSignalChannel
|
||||
? resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
@@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: {
|
||||
throwIfAborted(abortSignal);
|
||||
let signalChunks =
|
||||
textLimit === undefined
|
||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY)
|
||||
: markdownToSignalTextChunks(text, textLimit);
|
||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||
tableMode: signalTableMode,
|
||||
})
|
||||
: markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode });
|
||||
if (signalChunks.length === 0 && text) {
|
||||
signalChunks = [{ text, styles: [] }];
|
||||
}
|
||||
@@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: {
|
||||
|
||||
const sendSignalMedia = async (caption: string, mediaUrl: string) => {
|
||||
throwIfAborted(abortSignal);
|
||||
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? {
|
||||
const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, {
|
||||
tableMode: signalTableMode,
|
||||
})[0] ?? {
|
||||
text: caption,
|
||||
styles: [],
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should contain bullet points with header:value format
|
||||
expect(ir.text).toContain("• Value: 1");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
@@ -29,7 +29,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// First column becomes row label
|
||||
expect(ir.text).toContain("Speed");
|
||||
expect(ir.text).toContain("Scale");
|
||||
@@ -40,22 +40,20 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
expect(ir.text).toContain("• Postgres: Large");
|
||||
});
|
||||
|
||||
it("preserves flat mode as default", () => {
|
||||
it("leaves table syntax untouched by default", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md); // default is flat
|
||||
|
||||
// Flat mode uses tabs
|
||||
expect(ir.text).toContain("A");
|
||||
expect(ir.text).toContain("B");
|
||||
expect(ir.text).toContain("1");
|
||||
expect(ir.text).toContain("2");
|
||||
// Should not have bullet formatting
|
||||
const ir = markdownToIR(md);
|
||||
|
||||
// No table conversion by default
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.text).not.toContain("•");
|
||||
expect(ir.styles.some((style) => style.style === "code_block")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty cells gracefully", () => {
|
||||
@@ -67,7 +65,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should handle empty cell without crashing
|
||||
expect(ir.text).toContain("B");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
@@ -81,11 +79,41 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should have bold style for row label
|
||||
const hasRowLabelBold = ir.styles.some(
|
||||
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1"
|
||||
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1",
|
||||
);
|
||||
expect(hasRowLabelBold).toBe(true);
|
||||
});
|
||||
|
||||
it("renders tables as code blocks in code mode", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.styles.some((style) => style.style === "code_block")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves inline styles and links in bullets mode", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| _Row_ | [Link](https://example.com) |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
const hasItalic = ir.styles.some(
|
||||
(s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row",
|
||||
);
|
||||
expect(hasItalic).toBe(true);
|
||||
expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
|
||||
type ListState = {
|
||||
type: "bullet" | "ordered";
|
||||
@@ -12,24 +13,8 @@ type LinkState = {
|
||||
labelStart: number;
|
||||
};
|
||||
|
||||
type TableCell = {
|
||||
content: string;
|
||||
isHeader: boolean;
|
||||
};
|
||||
|
||||
type TableRow = TableCell[];
|
||||
|
||||
type TableState = {
|
||||
headers: string[];
|
||||
rows: TableRow[];
|
||||
currentRow: TableCell[];
|
||||
currentCell: string;
|
||||
inHeader: boolean;
|
||||
};
|
||||
|
||||
type RenderEnv = {
|
||||
listStack: ListState[];
|
||||
linkStack: LinkState[];
|
||||
};
|
||||
|
||||
type MarkdownToken = {
|
||||
@@ -65,19 +50,36 @@ type OpenStyle = {
|
||||
start: number;
|
||||
};
|
||||
|
||||
export type TableRenderMode = "flat" | "bullets";
|
||||
|
||||
type RenderState = {
|
||||
type RenderTarget = {
|
||||
text: string;
|
||||
styles: MarkdownStyleSpan[];
|
||||
openStyles: OpenStyle[];
|
||||
links: MarkdownLinkSpan[];
|
||||
linkStack: LinkState[];
|
||||
};
|
||||
|
||||
type TableCell = {
|
||||
text: string;
|
||||
styles: MarkdownStyleSpan[];
|
||||
links: MarkdownLinkSpan[];
|
||||
};
|
||||
|
||||
type TableState = {
|
||||
headers: TableCell[];
|
||||
rows: TableCell[][];
|
||||
currentRow: TableCell[];
|
||||
currentCell: RenderTarget | null;
|
||||
inHeader: boolean;
|
||||
};
|
||||
|
||||
type RenderState = RenderTarget & {
|
||||
env: RenderEnv;
|
||||
headingStyle: "none" | "bold";
|
||||
blockquotePrefix: string;
|
||||
enableSpoilers: boolean;
|
||||
tableMode: TableRenderMode;
|
||||
tableMode: MarkdownTableMode;
|
||||
table: TableState | null;
|
||||
hasTables: boolean;
|
||||
};
|
||||
|
||||
export type MarkdownParseOptions = {
|
||||
@@ -86,8 +88,8 @@ export type MarkdownParseOptions = {
|
||||
headingStyle?: "none" | "bold";
|
||||
blockquotePrefix?: string;
|
||||
autolink?: boolean;
|
||||
/** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */
|
||||
tableMode?: TableRenderMode;
|
||||
/** How to render tables (off|bullets|code). Default: off. */
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||
@@ -98,7 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||
typographer: false,
|
||||
});
|
||||
md.enable("strikethrough");
|
||||
md.enable("table");
|
||||
if (options.tableMode && options.tableMode !== "off") {
|
||||
md.enable("table");
|
||||
} else {
|
||||
md.disable("table");
|
||||
}
|
||||
if (options.autolink === false) {
|
||||
md.disable("autolink");
|
||||
}
|
||||
@@ -166,28 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function initRenderTarget(): RenderTarget {
|
||||
return {
|
||||
text: "",
|
||||
styles: [],
|
||||
openStyles: [],
|
||||
links: [],
|
||||
linkStack: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRenderTarget(state: RenderState): RenderTarget {
|
||||
return state.table?.currentCell ?? state;
|
||||
}
|
||||
|
||||
function appendText(state: RenderState, value: string) {
|
||||
if (!value) return;
|
||||
// If we're inside a table cell in bullets mode, collect into cell buffer
|
||||
if (state.table && state.tableMode === "bullets") {
|
||||
state.table.currentCell += value;
|
||||
return;
|
||||
}
|
||||
state.text += value;
|
||||
const target = resolveRenderTarget(state);
|
||||
target.text += value;
|
||||
}
|
||||
|
||||
function openStyle(state: RenderState, style: MarkdownStyle) {
|
||||
state.openStyles.push({ style, start: state.text.length });
|
||||
const target = resolveRenderTarget(state);
|
||||
target.openStyles.push({ style, start: target.text.length });
|
||||
}
|
||||
|
||||
function closeStyle(state: RenderState, style: MarkdownStyle) {
|
||||
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
if (state.openStyles[i]?.style === style) {
|
||||
const start = state.openStyles[i].start;
|
||||
state.openStyles.splice(i, 1);
|
||||
const end = state.text.length;
|
||||
const target = resolveRenderTarget(state);
|
||||
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
if (target.openStyles[i]?.style === style) {
|
||||
const start = target.openStyles[i].start;
|
||||
target.openStyles.splice(i, 1);
|
||||
const end = target.text.length;
|
||||
if (end > start) {
|
||||
state.styles.push({ start, end, style });
|
||||
target.styles.push({ start, end, style });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -212,39 +230,37 @@ function appendListPrefix(state: RenderState) {
|
||||
|
||||
function renderInlineCode(state: RenderState, content: string) {
|
||||
if (!content) return;
|
||||
// In bullets mode inside table, just add text without styling
|
||||
if (state.table && state.tableMode === "bullets") {
|
||||
state.table.currentCell += content;
|
||||
return;
|
||||
}
|
||||
const start = state.text.length;
|
||||
state.text += content;
|
||||
state.styles.push({ start, end: start + content.length, style: "code" });
|
||||
const target = resolveRenderTarget(state);
|
||||
const start = target.text.length;
|
||||
target.text += content;
|
||||
target.styles.push({ start, end: start + content.length, style: "code" });
|
||||
}
|
||||
|
||||
function renderCodeBlock(state: RenderState, content: string) {
|
||||
let code = content ?? "";
|
||||
if (!code.endsWith("\n")) code = `${code}\n`;
|
||||
const start = state.text.length;
|
||||
state.text += code;
|
||||
state.styles.push({ start, end: start + code.length, style: "code_block" });
|
||||
const target = resolveRenderTarget(state);
|
||||
const start = target.text.length;
|
||||
target.text += code;
|
||||
target.styles.push({ start, end: start + code.length, style: "code_block" });
|
||||
if (state.env.listStack.length === 0) {
|
||||
state.text += "\n";
|
||||
target.text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkClose(state: RenderState) {
|
||||
const link = state.env.linkStack.pop();
|
||||
const target = resolveRenderTarget(state);
|
||||
const link = target.linkStack.pop();
|
||||
if (!link?.href) return;
|
||||
const href = link.href.trim();
|
||||
if (!href) return;
|
||||
const start = link.labelStart;
|
||||
const end = state.text.length;
|
||||
const end = target.text.length;
|
||||
if (end <= start) {
|
||||
state.links.push({ start, end, href });
|
||||
target.links.push({ start, end, href });
|
||||
return;
|
||||
}
|
||||
state.links.push({ start, end, href });
|
||||
target.links.push({ start, end, href });
|
||||
}
|
||||
|
||||
function initTableState(): TableState {
|
||||
@@ -252,14 +268,72 @@ function initTableState(): TableState {
|
||||
headers: [],
|
||||
rows: [],
|
||||
currentRow: [],
|
||||
currentCell: "",
|
||||
currentCell: null,
|
||||
inHeader: false,
|
||||
};
|
||||
}
|
||||
|
||||
function finishTableCell(cell: RenderTarget): TableCell {
|
||||
closeRemainingStyles(cell);
|
||||
return {
|
||||
text: cell.text,
|
||||
styles: cell.styles,
|
||||
links: cell.links,
|
||||
};
|
||||
}
|
||||
|
||||
function trimCell(cell: TableCell): TableCell {
|
||||
const text = cell.text;
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && /\s/.test(text[start] ?? "")) start += 1;
|
||||
while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1;
|
||||
if (start === 0 && end === text.length) return cell;
|
||||
const trimmedText = text.slice(start, end);
|
||||
const trimmedLength = trimmedText.length;
|
||||
const trimmedStyles: MarkdownStyleSpan[] = [];
|
||||
for (const span of cell.styles) {
|
||||
const sliceStart = Math.max(0, span.start - start);
|
||||
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
||||
if (sliceEnd > sliceStart) {
|
||||
trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style });
|
||||
}
|
||||
}
|
||||
const trimmedLinks: MarkdownLinkSpan[] = [];
|
||||
for (const span of cell.links) {
|
||||
const sliceStart = Math.max(0, span.start - start);
|
||||
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
||||
if (sliceEnd > sliceStart) {
|
||||
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
|
||||
}
|
||||
}
|
||||
return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks };
|
||||
}
|
||||
|
||||
function appendCell(state: RenderState, cell: TableCell) {
|
||||
if (!cell.text) return;
|
||||
const start = state.text.length;
|
||||
state.text += cell.text;
|
||||
for (const span of cell.styles) {
|
||||
state.styles.push({
|
||||
start: start + span.start,
|
||||
end: start + span.end,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
for (const link of cell.links) {
|
||||
state.links.push({
|
||||
start: start + link.start,
|
||||
end: start + link.end,
|
||||
href: link.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableAsBullets(state: RenderState) {
|
||||
if (!state.table) return;
|
||||
const { headers, rows } = state.table;
|
||||
const headers = state.table.headers.map(trimCell);
|
||||
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||
|
||||
// If no headers or rows, skip
|
||||
if (headers.length === 0 && rows.length === 0) return;
|
||||
@@ -273,22 +347,31 @@ function renderTableAsBullets(state: RenderState) {
|
||||
for (const row of rows) {
|
||||
if (row.length === 0) continue;
|
||||
|
||||
const rowLabel = row[0]?.content?.trim() || "";
|
||||
if (rowLabel) {
|
||||
// Bold the row label
|
||||
const start = state.text.length;
|
||||
state.text += rowLabel;
|
||||
state.styles.push({ start, end: state.text.length, style: "bold" });
|
||||
const rowLabel = row[0];
|
||||
if (rowLabel?.text) {
|
||||
const labelStart = state.text.length;
|
||||
appendCell(state, rowLabel);
|
||||
const labelEnd = state.text.length;
|
||||
if (labelEnd > labelStart) {
|
||||
state.styles.push({ start: labelStart, end: labelEnd, style: "bold" });
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
|
||||
// Add each column as a bullet point
|
||||
for (let i = 1; i < row.length; i++) {
|
||||
const header = headers[i]?.trim() || `Column ${i}`;
|
||||
const value = row[i]?.content?.trim() || "";
|
||||
if (value) {
|
||||
state.text += `• ${header}: ${value}\n`;
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) continue;
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
} else {
|
||||
state.text += `Column ${i}: `;
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
@@ -296,37 +379,77 @@ function renderTableAsBullets(state: RenderState) {
|
||||
// Simple table: just list headers and values
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const header = headers[i]?.trim() || "";
|
||||
const value = row[i]?.content?.trim() || "";
|
||||
if (header && value) {
|
||||
state.text += `• ${header}: ${value}\n`;
|
||||
} else if (value) {
|
||||
state.text += `• ${value}\n`;
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) continue;
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableAsFlat(state: RenderState) {
|
||||
function renderTableAsCode(state: RenderState) {
|
||||
if (!state.table) return;
|
||||
const { headers, rows } = state.table;
|
||||
const headers = state.table.headers.map(trimCell);
|
||||
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||
|
||||
// Render headers
|
||||
for (const header of headers) {
|
||||
state.text += header.trim() + "\t";
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
state.text = state.text.trimEnd() + "\n";
|
||||
}
|
||||
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
|
||||
if (columnCount === 0) return;
|
||||
|
||||
// Render rows
|
||||
for (const row of rows) {
|
||||
for (const cell of row) {
|
||||
state.text += cell.content.trim() + "\t";
|
||||
const widths = Array.from({ length: columnCount }, () => 0);
|
||||
const updateWidths = (cells: TableCell[]) => {
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
const cell = cells[i];
|
||||
const width = cell?.text.length ?? 0;
|
||||
if (widths[i] < width) widths[i] = width;
|
||||
}
|
||||
state.text = state.text.trimEnd() + "\n";
|
||||
};
|
||||
updateWidths(headers);
|
||||
for (const row of rows) updateWidths(row);
|
||||
|
||||
const codeStart = state.text.length;
|
||||
|
||||
const appendRow = (cells: TableCell[]) => {
|
||||
state.text += "|";
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
state.text += " ";
|
||||
const cell = cells[i];
|
||||
if (cell) appendCell(state, cell);
|
||||
const pad = widths[i] - (cell?.text.length ?? 0);
|
||||
if (pad > 0) state.text += " ".repeat(pad);
|
||||
state.text += " |";
|
||||
}
|
||||
state.text += "\n";
|
||||
};
|
||||
|
||||
const appendDivider = () => {
|
||||
state.text += "|";
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
const dashCount = Math.max(3, widths[i]);
|
||||
state.text += ` ${"-".repeat(dashCount)} |`;
|
||||
}
|
||||
state.text += "\n";
|
||||
};
|
||||
|
||||
appendRow(headers);
|
||||
appendDivider();
|
||||
for (const row of rows) {
|
||||
appendRow(row);
|
||||
}
|
||||
|
||||
const codeEnd = state.text.length;
|
||||
if (codeEnd > codeStart) {
|
||||
state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" });
|
||||
}
|
||||
if (state.env.listStack.length === 0) {
|
||||
state.text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
break;
|
||||
case "link_open": {
|
||||
const href = getAttr(token, "href") ?? "";
|
||||
state.env.linkStack.push({ href, labelStart: state.text.length });
|
||||
const target = resolveRenderTarget(state);
|
||||
target.linkStack.push({ href, labelStart: target.text.length });
|
||||
break;
|
||||
}
|
||||
case "link_close":
|
||||
@@ -428,15 +552,18 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
|
||||
// Table handling
|
||||
case "table_open":
|
||||
if (state.tableMode === "bullets") {
|
||||
if (state.tableMode !== "off") {
|
||||
state.table = initTableState();
|
||||
state.hasTables = true;
|
||||
}
|
||||
break;
|
||||
case "table_close":
|
||||
if (state.tableMode === "bullets" && state.table) {
|
||||
renderTableAsBullets(state);
|
||||
} else if (state.tableMode === "flat" && state.table) {
|
||||
renderTableAsFlat(state);
|
||||
if (state.table) {
|
||||
if (state.tableMode === "bullets") {
|
||||
renderTableAsBullets(state);
|
||||
} else if (state.tableMode === "code") {
|
||||
renderTableAsCode(state);
|
||||
}
|
||||
}
|
||||
state.table = null;
|
||||
break;
|
||||
@@ -461,33 +588,24 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
case "tr_close":
|
||||
if (state.table) {
|
||||
if (state.table.inHeader) {
|
||||
state.table.headers = state.table.currentRow.map((c) => c.content);
|
||||
state.table.headers = state.table.currentRow;
|
||||
} else {
|
||||
state.table.rows.push(state.table.currentRow);
|
||||
}
|
||||
state.table.currentRow = [];
|
||||
} else if (state.tableMode === "flat") {
|
||||
// Legacy flat mode without table state
|
||||
state.text += "\n";
|
||||
}
|
||||
break;
|
||||
case "th_open":
|
||||
case "td_open":
|
||||
if (state.table) {
|
||||
state.table.currentCell = "";
|
||||
state.table.currentCell = initRenderTarget();
|
||||
}
|
||||
break;
|
||||
case "th_close":
|
||||
case "td_close":
|
||||
if (state.table) {
|
||||
state.table.currentRow.push({
|
||||
content: state.table.currentCell,
|
||||
isHeader: token.type === "th_close",
|
||||
});
|
||||
state.table.currentCell = "";
|
||||
} else if (state.tableMode === "flat") {
|
||||
// Legacy flat mode without table state
|
||||
state.text += "\t";
|
||||
if (state.table?.currentCell) {
|
||||
state.table.currentRow.push(finishTableCell(state.table.currentCell));
|
||||
state.table.currentCell = null;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -501,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
}
|
||||
}
|
||||
|
||||
function closeRemainingStyles(state: RenderState) {
|
||||
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
const open = state.openStyles[i];
|
||||
const end = state.text.length;
|
||||
function closeRemainingStyles(target: RenderTarget) {
|
||||
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
const open = target.openStyles[i];
|
||||
const end = target.text.length;
|
||||
if (end > open.start) {
|
||||
state.styles.push({
|
||||
target.styles.push({
|
||||
start: open.start,
|
||||
end,
|
||||
style: open.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
state.openStyles = [];
|
||||
target.openStyles = [];
|
||||
}
|
||||
|
||||
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
|
||||
@@ -594,26 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
|
||||
}
|
||||
|
||||
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
|
||||
const env: RenderEnv = { listStack: [], linkStack: [] };
|
||||
return markdownToIRWithMeta(markdown, options).ir;
|
||||
}
|
||||
|
||||
export function markdownToIRWithMeta(
|
||||
markdown: string,
|
||||
options: MarkdownParseOptions = {},
|
||||
): { ir: MarkdownIR; hasTables: boolean } {
|
||||
const env: RenderEnv = { listStack: [] };
|
||||
const md = createMarkdownIt(options);
|
||||
const tokens = md.parse(markdown ?? "", env as unknown as object);
|
||||
if (options.enableSpoilers) {
|
||||
applySpoilerTokens(tokens as MarkdownToken[]);
|
||||
}
|
||||
|
||||
const tableMode = options.tableMode ?? "flat";
|
||||
const tableMode = options.tableMode ?? "off";
|
||||
|
||||
const state: RenderState = {
|
||||
text: "",
|
||||
styles: [],
|
||||
openStyles: [],
|
||||
links: [],
|
||||
linkStack: [],
|
||||
env,
|
||||
headingStyle: options.headingStyle ?? "none",
|
||||
blockquotePrefix: options.blockquotePrefix ?? "",
|
||||
enableSpoilers: options.enableSpoilers ?? false,
|
||||
tableMode,
|
||||
table: null,
|
||||
hasTables: false,
|
||||
};
|
||||
|
||||
renderTokens(tokens as MarkdownToken[], state);
|
||||
@@ -631,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
|
||||
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
|
||||
|
||||
return {
|
||||
text: finalText,
|
||||
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
|
||||
links: clampLinkSpans(state.links, finalLength),
|
||||
ir: {
|
||||
text: finalText,
|
||||
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
|
||||
links: clampLinkSpans(state.links, finalLength),
|
||||
},
|
||||
hasTables: state.hasTables,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
34
src/markdown/tables.ts
Normal file
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,
|
||||
DmConfig,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
MarkdownTableMode,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
@@ -92,6 +94,8 @@ export {
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
MarkdownTableModeSchema,
|
||||
normalizeAllowFrom,
|
||||
requireOpenAllowFrom,
|
||||
} from "../config/zod-schema.core.js";
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../config/group-policy.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||
import {
|
||||
@@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js";
|
||||
import { probeIMessage } from "../../imessage/probe.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { shouldLogVerbose } from "../../globals.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import { normalizeLogLevel } from "../../logging/levels.js";
|
||||
import { isVoiceCompatibleAudio } from "../../media/audio.js";
|
||||
@@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
chunkText,
|
||||
resolveTextChunkLimit,
|
||||
hasControlCommand,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
|
||||
@@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers =
|
||||
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
|
||||
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
|
||||
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
|
||||
type ResolveMarkdownTableMode =
|
||||
typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode;
|
||||
type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables;
|
||||
type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand;
|
||||
type IsControlCommandMessage =
|
||||
typeof import("../../auto-reply/command-detection.js").isControlCommandMessage;
|
||||
@@ -168,6 +171,8 @@ export type PluginRuntime = {
|
||||
chunkText: ChunkText;
|
||||
resolveTextChunkLimit: ResolveTextChunkLimit;
|
||||
hasControlCommand: HasControlCommand;
|
||||
resolveMarkdownTableMode: ResolveMarkdownTableMode;
|
||||
convertMarkdownTables: ConvertMarkdownTables;
|
||||
};
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type MarkdownIR,
|
||||
type MarkdownStyle,
|
||||
} from "../markdown/ir.js";
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
|
||||
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER";
|
||||
|
||||
@@ -18,6 +19,10 @@ export type SignalFormattedText = {
|
||||
styles: SignalTextStyleRange[];
|
||||
};
|
||||
|
||||
type SignalMarkdownOptions = {
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
type SignalStyleSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -188,22 +193,31 @@ function renderSignalText(ir: MarkdownIR): SignalFormattedText {
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownToSignalText(markdown: string): SignalFormattedText {
|
||||
export function markdownToSignalText(
|
||||
markdown: string,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderSignalText(ir);
|
||||
}
|
||||
|
||||
export function markdownToSignalTextChunks(markdown: string, limit: number): SignalFormattedText[] {
|
||||
export function markdownToSignalTextChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const chunks = chunkMarkdownIR(ir, limit);
|
||||
return chunks.map((chunk) => renderSignalText(chunk));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -164,7 +165,12 @@ export async function sendMessageSignal(
|
||||
if (textMode === "plain") {
|
||||
textStyles = opts.textStyles ?? [];
|
||||
} else {
|
||||
const formatted = markdownToSignalText(message);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const formatted = markdownToSignalText(message, { tableMode });
|
||||
message = formatted.text;
|
||||
textStyles = formatted.styles;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js";
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
import { renderMarkdownWithMarkers } from "../markdown/render.js";
|
||||
|
||||
// Escape special characters for Slack mrkdwn format.
|
||||
@@ -83,12 +84,20 @@ function buildSlackLink(link: MarkdownLinkSpan, text: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownToSlackMrkdwn(markdown: string): string {
|
||||
type SlackMarkdownOptions = {
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
export function markdownToSlackMrkdwn(
|
||||
markdown: string,
|
||||
options: SlackMarkdownOptions = {},
|
||||
): string {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: {
|
||||
@@ -103,12 +112,17 @@ export function markdownToSlackMrkdwn(markdown: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] {
|
||||
export function markdownToSlackMrkdwnChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: SlackMarkdownOptions = {},
|
||||
): string[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const chunks = chunkMarkdownIR(ir, limit);
|
||||
return chunks.map((chunk) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "../format.js";
|
||||
import { sendMessageSlack } from "../send.js";
|
||||
@@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: {
|
||||
respond: SlackRespondFn;
|
||||
ephemeral: boolean;
|
||||
textLimit: number;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}) {
|
||||
const messages: string[] = [];
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
@@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combined) continue;
|
||||
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
|
||||
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
|
||||
tableMode: params.tableMode,
|
||||
})) {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
@@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
tableMode: resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: route.accountId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
@@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
tableMode: resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: route.accountId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { parseSlackTarget } from "./targets.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -143,7 +144,12 @@ export async function sendMessageSlack(
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode });
|
||||
const mediaMaxBytes =
|
||||
typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
||||
import { clearHistoryEntries } from "../auto-reply/reply/history.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
@@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, route.agentId),
|
||||
};
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
@@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({
|
||||
replyToMode,
|
||||
textLimit,
|
||||
messageThreadId: resolvedThreadId,
|
||||
tableMode,
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
@@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({
|
||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
||||
},
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
||||
const systemPromptParts = [
|
||||
groupConfig?.systemPrompt?.trim() || null,
|
||||
@@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({
|
||||
replyToMode,
|
||||
textLimit,
|
||||
messageThreadId: resolvedThreadId,
|
||||
tableMode,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { mediaKindFromMime } from "../../media/constants.js";
|
||||
@@ -26,6 +27,7 @@ export async function deliverReplies(params: {
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
messageThreadId?: number;
|
||||
tableMode?: MarkdownTableMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
}) {
|
||||
@@ -49,7 +51,9 @@ export async function deliverReplies(params: {
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
if (mediaList.length === 0) {
|
||||
const chunks = markdownToTelegramChunks(reply.text || "", textLimit);
|
||||
const chunks = markdownToTelegramChunks(reply.text || "", textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||
replyToMessageId:
|
||||
@@ -139,7 +143,9 @@ export async function deliverReplies(params: {
|
||||
// Send deferred follow-up text right after the first media item.
|
||||
// Chunk it in case it's extremely long (same logic as text-only replies).
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit);
|
||||
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
const replyToMessageIdFollowup =
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type MarkdownIR,
|
||||
} from "../markdown/ir.js";
|
||||
import { renderMarkdownWithMarkers } from "../markdown/render.js";
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
|
||||
export type TelegramFormattedChunk = {
|
||||
html: string;
|
||||
@@ -46,12 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function markdownToTelegramHtml(markdown: string): string {
|
||||
export function markdownToTelegramHtml(
|
||||
markdown: string,
|
||||
options: { tableMode?: MarkdownTableMode } = {},
|
||||
): string {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: "bullets",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderTelegramHtml(ir);
|
||||
}
|
||||
@@ -59,12 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string {
|
||||
export function markdownToTelegramChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: { tableMode?: MarkdownTableMode } = {},
|
||||
): TelegramFormattedChunk[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: "bullets",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const chunks = chunkMarkdownIR(ir, limit);
|
||||
return chunks.map((chunk) => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
import { markdownToTelegramHtml } from "./format.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
||||
@@ -310,7 +311,12 @@ export async function sendMessageTelegram(
|
||||
throw new Error("Message must be non-empty for Telegram sends");
|
||||
}
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text, { tableMode });
|
||||
const textParams = hasThreadParams
|
||||
? {
|
||||
parse_mode: "HTML" as const,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
@@ -19,10 +21,13 @@ export async function deliverWebReply(params: {
|
||||
};
|
||||
connectionId?: string;
|
||||
skipLog?: boolean;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}) {
|
||||
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
|
||||
const replyStarted = Date.now();
|
||||
const textChunks = chunkMarkdownText(replyResult.text || "", textLimit);
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const convertedText = convertMarkdownTables(replyResult.text || "", tableMode);
|
||||
const textChunks = chunkMarkdownText(convertedText, textLimit);
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
recordSessionMetaFromInbound,
|
||||
resolveStorePath,
|
||||
} from "../../../config/sessions.js";
|
||||
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
import type { getChildLogger } from "../../../logging.js";
|
||||
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
|
||||
@@ -235,6 +236,11 @@ export async function processMessage(params: {
|
||||
: undefined;
|
||||
|
||||
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
});
|
||||
let didLogHeartbeatStrip = false;
|
||||
let didSendReply = false;
|
||||
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
|
||||
@@ -345,6 +351,7 @@ export async function processMessage(params: {
|
||||
connectionId: params.connectionId,
|
||||
// Tool + block updates are noisy; skip their log lines.
|
||||
skipLog: info.kind !== "final",
|
||||
tableMode,
|
||||
});
|
||||
didSendReply = true;
|
||||
if (info.kind === "tool") {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getChildLogger } from "../logging/logger.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizePollInput, type PollInput } from "../polls.js";
|
||||
import { toWhatsappJid } from "../utils.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
||||
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
|
||||
import { loadWebMedia } from "./media.js";
|
||||
|
||||
@@ -25,6 +28,13 @@ export async function sendMessageWhatsApp(
|
||||
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
||||
options.accountId,
|
||||
);
|
||||
const cfg = loadConfig();
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: resolvedAccountId ?? options.accountId,
|
||||
});
|
||||
text = convertMarkdownTables(text ?? "", tableMode);
|
||||
const logger = getChildLogger({
|
||||
module: "web-outbound",
|
||||
correlationId,
|
||||
|
||||
Reference in New Issue
Block a user