From bd7d362d3b32cb73ac5946c0438631c3c270072c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 00:12:29 +0000 Subject: [PATCH] refactor: unify markdown formatting pipeline --- docs/concepts/markdown-formatting.md | 34 ++ src/channels/plugins/outbound/telegram.ts | 6 +- src/infra/outbound/deliver.test.ts | 42 +- src/infra/outbound/deliver.ts | 73 +++- src/markdown/ir.ts | 496 ++++++++++++++++++++++ src/markdown/render.ts | 137 ++++++ src/signal/format.test.ts | 67 +++ src/signal/format.ts | 220 ++++++++++ src/signal/send.ts | 22 + src/slack/format.test.ts | 6 +- src/slack/format.ts | 247 +++-------- src/slack/monitor/replies.ts | 21 +- src/slack/send.ts | 7 +- src/telegram/bot/delivery.ts | 23 +- src/telegram/format.ts | 184 +++----- src/telegram/send.ts | 10 +- 16 files changed, 1245 insertions(+), 350 deletions(-) create mode 100644 docs/concepts/markdown-formatting.md create mode 100644 src/markdown/ir.ts create mode 100644 src/markdown/render.ts create mode 100644 src/signal/format.test.ts create mode 100644 src/signal/format.ts diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md new file mode 100644 index 000000000..1d23a0f0b --- /dev/null +++ b/docs/concepts/markdown-formatting.md @@ -0,0 +1,34 @@ +--- +summary: "Markdown formatting pipeline for outbound channels" +read_when: + - You are changing markdown formatting or chunking for outbound channels + - You are adding a new channel formatter or style mapping +--- +# Markdown formatting + +Clawdbot formats outbound Markdown by converting it into a shared intermediate +representation (IR) before rendering channel-specific output. + +## Pipeline + +1. **Parse Markdown -> IR** + - IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. + - Offsets are UTF-16 code units so Signal style ranges align with its API. +2. **Chunk IR (format-first)** + - Chunking happens on the IR text before rendering. + - Inline formatting does not split across chunks; spans are sliced per chunk. +3. **Render per channel** + - **Slack:** mrkdwn tokens (bold/italic/strike/code), links as ``. + - **Telegram:** HTML tags (``, ``, ``, ``, `
`, ``).
+   - **Signal:** plain text + `text-style` ranges; links become `label (url)` when label differs.
+
+## Link policy
+
+- **Slack:** `[label](url)` -> ``; bare URLs are left as-is.
+- **Telegram:** `[label](url)` -> `label` (HTML parse mode).
+- **Signal:** `[label](url)` -> `label (url)` unless label matches url.
+
+## Spoilers
+
+Spoiler markers (`||spoiler||`) are parsed only for Signal, where they map to
+SPOILER style ranges. Other channels treat them as plain text.
diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts
index 1124c821b..db6b8a421 100644
--- a/src/channels/plugins/outbound/telegram.ts
+++ b/src/channels/plugins/outbound/telegram.ts
@@ -1,4 +1,4 @@
-import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
+import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
 import { sendMessageTelegram } from "../../../telegram/send.js";
 import type { ChannelOutboundAdapter } from "../types.js";
 
@@ -10,7 +10,7 @@ function parseReplyToMessageId(replyToId?: string | null) {
 
 export const telegramOutbound: ChannelOutboundAdapter = {
   deliveryMode: "direct",
-  chunker: chunkMarkdownText,
+  chunker: markdownToTelegramHtmlChunks,
   textChunkLimit: 4000,
   resolveTarget: ({ to }) => {
     const trimmed = to?.trim();
@@ -27,6 +27,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
     const replyToMessageId = parseReplyToMessageId(replyToId);
     const result = await send(to, text, {
       verbose: false,
+      textMode: "html",
       messageThreadId: threadId ?? undefined,
       replyToMessageId,
       accountId: accountId ?? undefined,
@@ -39,6 +40,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
     const result = await send(to, text, {
       verbose: false,
       mediaUrl,
+      textMode: "html",
       messageThreadId: threadId ?? undefined,
       replyToMessageId,
       accountId: accountId ?? undefined,
diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts
index 4a291e9e6..921681342 100644
--- a/src/infra/outbound/deliver.test.ts
+++ b/src/infra/outbound/deliver.test.ts
@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vitest";
 
 import type { ClawdbotConfig } from "../../config/config.js";
+import { markdownToSignalTextChunks } from "../../signal/format.js";
 import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
 
 describe("deliverOutboundPayloads", () => {
@@ -22,7 +23,9 @@ describe("deliverOutboundPayloads", () => {
 
       expect(sendTelegram).toHaveBeenCalledTimes(2);
       for (const call of sendTelegram.mock.calls) {
-        expect(call[2]).toEqual(expect.objectContaining({ accountId: undefined, verbose: false }));
+        expect(call[2]).toEqual(
+          expect.objectContaining({ accountId: undefined, verbose: false, textMode: "html" }),
+        );
       }
       expect(results).toHaveLength(2);
       expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
@@ -53,7 +56,7 @@ describe("deliverOutboundPayloads", () => {
     expect(sendTelegram).toHaveBeenCalledWith(
       "123",
       "hi",
-      expect.objectContaining({ accountId: "default", verbose: false }),
+      expect.objectContaining({ accountId: "default", verbose: false, textMode: "html" }),
     );
   });
 
@@ -75,11 +78,46 @@ describe("deliverOutboundPayloads", () => {
       expect.objectContaining({
         mediaUrl: "https://x.test/a.jpg",
         maxBytes: 2 * 1024 * 1024,
+        textMode: "plain",
+        textStyles: [],
       }),
     );
     expect(results[0]).toMatchObject({ channel: "signal", messageId: "s1" });
   });
 
+  it("chunks Signal markdown using the format-first chunker", async () => {
+    const sendSignal = vi
+      .fn()
+      .mockResolvedValue({ messageId: "s1", timestamp: 123 });
+    const cfg: ClawdbotConfig = {
+      channels: { signal: { textChunkLimit: 20 } },
+    };
+    const text = `Intro\\n\\n\`\`\`\`md\\n${"y".repeat(60)}\\n\`\`\`\\n\\nOutro`;
+    const expectedChunks = markdownToSignalTextChunks(text, 20);
+
+    await deliverOutboundPayloads({
+      cfg,
+      channel: "signal",
+      to: "+1555",
+      payloads: [{ text }],
+      deps: { sendSignal },
+    });
+
+    expect(sendSignal).toHaveBeenCalledTimes(expectedChunks.length);
+    expectedChunks.forEach((chunk, index) => {
+      expect(sendSignal).toHaveBeenNthCalledWith(
+        index + 1,
+        "+1555",
+        chunk.text,
+        expect.objectContaining({
+          accountId: undefined,
+          textMode: "plain",
+          textStyles: chunk.styles,
+        }),
+      );
+    });
+  });
+
   it("chunks WhatsApp text and returns all results", async () => {
     const sendWhatsApp = vi
       .fn()
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 368c7b949..664fcc624 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -1,11 +1,13 @@
 import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
 import type { ReplyPayload } from "../../auto-reply/types.js";
+import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
 import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
 import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
 import type { ClawdbotConfig } from "../../config/config.js";
 import type { sendMessageDiscord } from "../../discord/send.js";
 import type { sendMessageIMessage } from "../../imessage/send.js";
-import type { sendMessageSignal } from "../../signal/send.js";
+import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
+import { sendMessageSignal } from "../../signal/send.js";
 import type { sendMessageSlack } from "../../slack/send.js";
 import type { sendMessageTelegram } from "../../telegram/send.js";
 import type { sendMessageWhatsApp } from "../../web/outbound.js";
@@ -154,6 +156,7 @@ export async function deliverOutboundPayloads(params: {
   const accountId = params.accountId;
   const deps = params.deps;
   const abortSignal = params.abortSignal;
+  const sendSignal = params.deps?.sendSignal ?? sendMessageSignal;
   const results: OutboundDeliveryResult[] = [];
   const handler = await createChannelHandler({
     cfg,
@@ -170,6 +173,16 @@ export async function deliverOutboundPayloads(params: {
         fallbackLimit: handler.textChunkLimit,
       })
     : undefined;
+  const isSignalChannel = channel === "signal";
+  const signalMaxBytes = isSignalChannel
+    ? resolveChannelMediaMaxBytes({
+        cfg,
+        resolveChannelLimitMb: ({ cfg, accountId }) =>
+          cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
+          cfg.channels?.signal?.mediaMaxMb,
+        accountId,
+      })
+    : undefined;
 
   const sendTextChunks = async (text: string) => {
     throwIfAborted(abortSignal);
@@ -183,13 +196,63 @@ export async function deliverOutboundPayloads(params: {
     }
   };
 
+  const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => {
+    throwIfAborted(abortSignal);
+    return {
+      channel: "signal" as const,
+      ...(await sendSignal(to, text, {
+        maxBytes: signalMaxBytes,
+        accountId: accountId ?? undefined,
+        textMode: "plain",
+        textStyles: styles,
+      })),
+    };
+  };
+
+  const sendSignalTextChunks = async (text: string) => {
+    throwIfAborted(abortSignal);
+    let signalChunks =
+      textLimit === undefined
+        ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY)
+        : markdownToSignalTextChunks(text, textLimit);
+    if (signalChunks.length === 0 && text) {
+      signalChunks = [{ text, styles: [] }];
+    }
+    for (const chunk of signalChunks) {
+      throwIfAborted(abortSignal);
+      results.push(await sendSignalText(chunk.text, chunk.styles));
+    }
+  };
+
+  const sendSignalMedia = async (caption: string, mediaUrl: string) => {
+    throwIfAborted(abortSignal);
+    const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? {
+      text: caption,
+      styles: [],
+    };
+    return {
+      channel: "signal" as const,
+      ...(await sendSignal(to, formatted.text, {
+        mediaUrl,
+        maxBytes: signalMaxBytes,
+        accountId: accountId ?? undefined,
+        textMode: "plain",
+        textStyles: formatted.styles,
+      })),
+    };
+  };
+
   const normalizedPayloads = normalizeOutboundPayloads(payloads);
   for (const payload of normalizedPayloads) {
     try {
       throwIfAborted(abortSignal);
       params.onPayload?.(payload);
       if (payload.mediaUrls.length === 0) {
-        await sendTextChunks(payload.text);
+        if (isSignalChannel) {
+          await sendSignalTextChunks(payload.text);
+        } else {
+          await sendTextChunks(payload.text);
+        }
         continue;
       }
 
@@ -198,7 +261,11 @@ export async function deliverOutboundPayloads(params: {
         throwIfAborted(abortSignal);
         const caption = first ? payload.text : "";
         first = false;
-        results.push(await handler.sendMedia(caption, url));
+        if (isSignalChannel) {
+          results.push(await sendSignalMedia(caption, url));
+        } else {
+          results.push(await handler.sendMedia(caption, url));
+        }
       }
     } catch (err) {
       if (!params.bestEffort) throw err;
diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts
new file mode 100644
index 000000000..9dc3922b6
--- /dev/null
+++ b/src/markdown/ir.ts
@@ -0,0 +1,496 @@
+import MarkdownIt from "markdown-it";
+
+import { chunkText } from "../auto-reply/chunk.js";
+
+type ListState = {
+  type: "bullet" | "ordered";
+  index: number;
+};
+
+type LinkState = {
+  href: string;
+  labelStart: number;
+};
+
+type RenderEnv = {
+  listStack: ListState[];
+  linkStack: LinkState[];
+};
+
+type MarkdownToken = {
+  type: string;
+  content?: string;
+  children?: MarkdownToken[];
+  attrs?: [string, string][];
+  attrGet?: (name: string) => string | null;
+};
+
+export type MarkdownStyle =
+  | "bold"
+  | "italic"
+  | "strikethrough"
+  | "code"
+  | "code_block"
+  | "spoiler";
+
+export type MarkdownStyleSpan = {
+  start: number;
+  end: number;
+  style: MarkdownStyle;
+};
+
+export type MarkdownLinkSpan = {
+  start: number;
+  end: number;
+  href: string;
+};
+
+export type MarkdownIR = {
+  text: string;
+  styles: MarkdownStyleSpan[];
+  links: MarkdownLinkSpan[];
+};
+
+type OpenStyle = {
+  style: MarkdownStyle;
+  start: number;
+};
+
+type RenderState = {
+  text: string;
+  styles: MarkdownStyleSpan[];
+  openStyles: OpenStyle[];
+  links: MarkdownLinkSpan[];
+  env: RenderEnv;
+  headingStyle: "none" | "bold";
+  blockquotePrefix: string;
+  enableSpoilers: boolean;
+};
+
+export type MarkdownParseOptions = {
+  linkify?: boolean;
+  enableSpoilers?: boolean;
+  headingStyle?: "none" | "bold";
+  blockquotePrefix?: string;
+  autolink?: boolean;
+};
+
+function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
+  const md = new MarkdownIt({
+    html: false,
+    linkify: options.linkify ?? true,
+    breaks: false,
+    typographer: false,
+  });
+  md.enable("strikethrough");
+  if (options.autolink === false) {
+    md.disable("autolink");
+  }
+  return md;
+}
+
+function getAttr(token: MarkdownToken, name: string): string | null {
+  if (token.attrGet) return token.attrGet(name);
+  if (token.attrs) {
+    for (const [key, value] of token.attrs) {
+      if (key === name) return value;
+    }
+  }
+  return null;
+}
+
+function createTextToken(base: MarkdownToken, content: string): MarkdownToken {
+  return { ...base, type: "text", content, children: undefined };
+}
+
+function applySpoilerTokens(tokens: MarkdownToken[]): void {
+  for (const token of tokens) {
+    if (token.children && token.children.length > 0) {
+      token.children = injectSpoilersIntoInline(token.children);
+    }
+  }
+}
+
+function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
+  const result: MarkdownToken[] = [];
+  const state = { spoilerOpen: false };
+
+  for (const token of tokens) {
+    if (token.type !== "text") {
+      result.push(token);
+      continue;
+    }
+
+    const content = token.content ?? "";
+    if (!content.includes("||")) {
+      result.push(token);
+      continue;
+    }
+
+    let index = 0;
+    while (index < content.length) {
+      const next = content.indexOf("||", index);
+      if (next === -1) {
+        if (index < content.length) {
+          result.push(createTextToken(token, content.slice(index)));
+        }
+        break;
+      }
+      if (next > index) {
+        result.push(createTextToken(token, content.slice(index, next)));
+      }
+      state.spoilerOpen = !state.spoilerOpen;
+      result.push({
+        type: state.spoilerOpen ? "spoiler_open" : "spoiler_close",
+      });
+      index = next + 2;
+    }
+  }
+
+  return result;
+}
+
+function appendText(state: RenderState, value: string) {
+  if (!value) return;
+  state.text += value;
+}
+
+function openStyle(state: RenderState, style: MarkdownStyle) {
+  state.openStyles.push({ style, start: state.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;
+      if (end > start) {
+        state.styles.push({ start, end, style });
+      }
+      return;
+    }
+  }
+}
+
+function appendParagraphSeparator(state: RenderState) {
+  if (state.env.listStack.length > 0) return;
+  appendText(state, "\n\n");
+}
+
+function appendListPrefix(state: RenderState) {
+  const stack = state.env.listStack;
+  const top = stack[stack.length - 1];
+  if (!top) return;
+  top.index += 1;
+  const indent = "  ".repeat(Math.max(0, stack.length - 1));
+  const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
+  appendText(state, `${indent}${prefix}`);
+}
+
+function renderInlineCode(state: RenderState, content: string) {
+  if (!content) return;
+  const start = state.text.length;
+  appendText(state, content);
+  state.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;
+  appendText(state, code);
+  state.styles.push({ start, end: start + code.length, style: "code_block" });
+  if (state.env.listStack.length === 0) {
+    appendText(state, "\n");
+  }
+}
+
+function handleLinkClose(state: RenderState) {
+  const link = state.env.linkStack.pop();
+  if (!link?.href) return;
+  const href = link.href.trim();
+  if (!href) return;
+  const start = link.labelStart;
+  const end = state.text.length;
+  if (end <= start) {
+    state.links.push({ start, end, href });
+    return;
+  }
+  state.links.push({ start, end, href });
+}
+
+function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
+  for (const token of tokens) {
+    switch (token.type) {
+      case "inline":
+        if (token.children) renderTokens(token.children, state);
+        break;
+      case "text":
+        appendText(state, token.content ?? "");
+        break;
+      case "em_open":
+        openStyle(state, "italic");
+        break;
+      case "em_close":
+        closeStyle(state, "italic");
+        break;
+      case "strong_open":
+        openStyle(state, "bold");
+        break;
+      case "strong_close":
+        closeStyle(state, "bold");
+        break;
+      case "s_open":
+        openStyle(state, "strikethrough");
+        break;
+      case "s_close":
+        closeStyle(state, "strikethrough");
+        break;
+      case "code_inline":
+        renderInlineCode(state, token.content ?? "");
+        break;
+      case "spoiler_open":
+        if (state.enableSpoilers) openStyle(state, "spoiler");
+        break;
+      case "spoiler_close":
+        if (state.enableSpoilers) closeStyle(state, "spoiler");
+        break;
+      case "link_open": {
+        const href = getAttr(token, "href") ?? "";
+        state.env.linkStack.push({ href, labelStart: state.text.length });
+        break;
+      }
+      case "link_close":
+        handleLinkClose(state);
+        break;
+      case "image":
+        appendText(state, token.content ?? "");
+        break;
+      case "softbreak":
+      case "hardbreak":
+        appendText(state, "\n");
+        break;
+      case "paragraph_close":
+        appendParagraphSeparator(state);
+        break;
+      case "heading_open":
+        if (state.headingStyle === "bold") openStyle(state, "bold");
+        break;
+      case "heading_close":
+        if (state.headingStyle === "bold") closeStyle(state, "bold");
+        appendParagraphSeparator(state);
+        break;
+      case "blockquote_open":
+        if (state.blockquotePrefix) appendText(state, state.blockquotePrefix);
+        break;
+      case "blockquote_close":
+        appendText(state, "\n");
+        break;
+      case "bullet_list_open":
+        state.env.listStack.push({ type: "bullet", index: 0 });
+        break;
+      case "bullet_list_close":
+        state.env.listStack.pop();
+        break;
+      case "ordered_list_open": {
+        const start = Number(getAttr(token, "start") ?? "1");
+        state.env.listStack.push({ type: "ordered", index: start - 1 });
+        break;
+      }
+      case "ordered_list_close":
+        state.env.listStack.pop();
+        break;
+      case "list_item_open":
+        appendListPrefix(state);
+        break;
+      case "list_item_close":
+        appendText(state, "\n");
+        break;
+      case "code_block":
+      case "fence":
+        renderCodeBlock(state, token.content ?? "");
+        break;
+      case "html_block":
+      case "html_inline":
+        appendText(state, token.content ?? "");
+        break;
+      case "table_open":
+      case "table_close":
+      case "thead_open":
+      case "thead_close":
+      case "tbody_open":
+      case "tbody_close":
+        break;
+      case "tr_close":
+        appendText(state, "\n");
+        break;
+      case "th_close":
+      case "td_close":
+        appendText(state, "\t");
+        break;
+      case "hr":
+        appendText(state, "\n");
+        break;
+      default:
+        if (token.children) renderTokens(token.children, state);
+        break;
+    }
+  }
+}
+
+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;
+    if (end > open.start) {
+      state.styles.push({
+        start: open.start,
+        end,
+        style: open.style,
+      });
+    }
+  }
+  state.openStyles = [];
+}
+
+function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
+  const clamped: MarkdownStyleSpan[] = [];
+  for (const span of spans) {
+    const start = Math.max(0, Math.min(span.start, maxLength));
+    const end = Math.max(start, Math.min(span.end, maxLength));
+    if (end > start) clamped.push({ start, end, style: span.style });
+  }
+  return clamped;
+}
+
+function clampLinkSpans(spans: MarkdownLinkSpan[], maxLength: number): MarkdownLinkSpan[] {
+  const clamped: MarkdownLinkSpan[] = [];
+  for (const span of spans) {
+    const start = Math.max(0, Math.min(span.start, maxLength));
+    const end = Math.max(start, Math.min(span.end, maxLength));
+    if (end > start) clamped.push({ start, end, href: span.href });
+  }
+  return clamped;
+}
+
+function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
+  const sorted = [...spans].sort((a, b) => {
+    if (a.start !== b.start) return a.start - b.start;
+    if (a.end !== b.end) return a.end - b.end;
+    return a.style.localeCompare(b.style);
+  });
+
+  const merged: MarkdownStyleSpan[] = [];
+  for (const span of sorted) {
+    const prev = merged[merged.length - 1];
+    if (prev && prev.style === span.style && span.start <= prev.end) {
+      prev.end = Math.max(prev.end, span.end);
+      continue;
+    }
+    merged.push({ ...span });
+  }
+  return merged;
+}
+
+function sliceStyleSpans(
+  spans: MarkdownStyleSpan[],
+  start: number,
+  end: number,
+): MarkdownStyleSpan[] {
+  if (spans.length === 0) return [];
+  const sliced: MarkdownStyleSpan[] = [];
+  for (const span of spans) {
+    const sliceStart = Math.max(span.start, start);
+    const sliceEnd = Math.min(span.end, end);
+    if (sliceEnd > sliceStart) {
+      sliced.push({
+        start: sliceStart - start,
+        end: sliceEnd - start,
+        style: span.style,
+      });
+    }
+  }
+  return mergeStyleSpans(sliced);
+}
+
+function sliceLinkSpans(
+  spans: MarkdownLinkSpan[],
+  start: number,
+  end: number,
+): MarkdownLinkSpan[] {
+  if (spans.length === 0) return [];
+  const sliced: MarkdownLinkSpan[] = [];
+  for (const span of spans) {
+    const sliceStart = Math.max(span.start, start);
+    const sliceEnd = Math.min(span.end, end);
+    if (sliceEnd > sliceStart) {
+      sliced.push({
+        start: sliceStart - start,
+        end: sliceEnd - start,
+        href: span.href,
+      });
+    }
+  }
+  return sliced;
+}
+
+export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
+  const env: RenderEnv = { listStack: [], linkStack: [] };
+  const md = createMarkdownIt(options);
+  const tokens = md.parse(markdown ?? "", env as unknown as object);
+  if (options.enableSpoilers) {
+    applySpoilerTokens(tokens as MarkdownToken[]);
+  }
+
+  const state: RenderState = {
+    text: "",
+    styles: [],
+    openStyles: [],
+    links: [],
+    env,
+    headingStyle: options.headingStyle ?? "none",
+    blockquotePrefix: options.blockquotePrefix ?? "",
+    enableSpoilers: options.enableSpoilers ?? false,
+  };
+
+  renderTokens(tokens as MarkdownToken[], state);
+  closeRemainingStyles(state);
+
+  const trimmedText = state.text.trimEnd();
+  const trimmedLength = trimmedText.length;
+
+  return {
+    text: trimmedText,
+    styles: mergeStyleSpans(clampStyleSpans(state.styles, trimmedLength)),
+    links: clampLinkSpans(state.links, trimmedLength),
+  };
+}
+
+export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
+  if (!ir.text) return [];
+  if (limit <= 0 || ir.text.length <= limit) return [ir];
+
+  const chunks = chunkText(ir.text, limit);
+  const results: MarkdownIR[] = [];
+  let cursor = 0;
+
+  chunks.forEach((chunk, index) => {
+    if (!chunk) return;
+    if (index > 0) {
+      while (cursor < ir.text.length && /\s/.test(ir.text[cursor] ?? "")) {
+        cursor += 1;
+      }
+    }
+    const start = cursor;
+    const end = Math.min(ir.text.length, start + chunk.length);
+    results.push({
+      text: chunk,
+      styles: sliceStyleSpans(ir.styles, start, end),
+      links: sliceLinkSpans(ir.links, start, end),
+    });
+    cursor = end;
+  });
+
+  return results;
+}
diff --git a/src/markdown/render.ts b/src/markdown/render.ts
new file mode 100644
index 000000000..33b62e672
--- /dev/null
+++ b/src/markdown/render.ts
@@ -0,0 +1,137 @@
+import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
+
+export type RenderStyleMarker = {
+  open: string;
+  close: string;
+};
+
+export type RenderStyleMap = Partial>;
+
+export type RenderLink = {
+  start: number;
+  end: number;
+  open: string;
+  close: string;
+};
+
+export type RenderOptions = {
+  styleMarkers: RenderStyleMap;
+  escapeText: (text: string) => string;
+  buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
+};
+
+const STYLE_ORDER: MarkdownStyle[] = [
+  "code_block",
+  "code",
+  "bold",
+  "italic",
+  "strikethrough",
+  "spoiler",
+];
+
+const STYLE_RANK = new Map(
+  STYLE_ORDER.map((style, index) => [style, index]),
+);
+
+function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
+  return [...spans].sort((a, b) => {
+    if (a.start !== b.start) return a.start - b.start;
+    if (a.end !== b.end) return b.end - a.end;
+    return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
+  });
+}
+
+export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
+  const text = ir.text ?? "";
+  if (!text) return "";
+
+  const styleMarkers = options.styleMarkers;
+  const styled = sortStyleSpans(
+    ir.styles.filter((span) => Boolean(styleMarkers[span.style])),
+  );
+
+  const boundaries = new Set();
+  boundaries.add(0);
+  boundaries.add(text.length);
+
+  const startsAt = new Map();
+  for (const span of styled) {
+    if (span.start === span.end) continue;
+    boundaries.add(span.start);
+    boundaries.add(span.end);
+    const bucket = startsAt.get(span.start);
+    if (bucket) bucket.push(span);
+    else startsAt.set(span.start, [span]);
+  }
+  for (const spans of startsAt.values()) {
+    spans.sort((a, b) => {
+      if (a.end !== b.end) return b.end - a.end;
+      return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
+    });
+  }
+
+  const linkStarts = new Map();
+  const linkEnds = new Map();
+  if (options.buildLink) {
+    for (const link of ir.links) {
+      if (link.start === link.end) continue;
+      const rendered = options.buildLink(link, text);
+      if (!rendered) continue;
+      boundaries.add(rendered.start);
+      boundaries.add(rendered.end);
+      const openBucket = linkStarts.get(rendered.start);
+      if (openBucket) openBucket.push(rendered);
+      else linkStarts.set(rendered.start, [rendered]);
+      const closeBucket = linkEnds.get(rendered.end);
+      if (closeBucket) closeBucket.push(rendered);
+      else linkEnds.set(rendered.end, [rendered]);
+    }
+  }
+
+  const points = [...boundaries].sort((a, b) => a - b);
+  const stack: MarkdownStyleSpan[] = [];
+  let out = "";
+
+  for (let i = 0; i < points.length; i += 1) {
+    const pos = points[i];
+
+    while (stack.length && stack[stack.length - 1]?.end === pos) {
+      const span = stack.pop();
+      if (!span) break;
+      const marker = styleMarkers[span.style];
+      if (marker) out += marker.close;
+    }
+
+    const closingLinks = linkEnds.get(pos);
+    if (closingLinks && closingLinks.length > 0) {
+      for (const link of closingLinks) {
+        out += link.close;
+      }
+    }
+
+    const openingLinks = linkStarts.get(pos);
+    if (openingLinks && openingLinks.length > 0) {
+      for (const link of openingLinks) {
+        out += link.open;
+      }
+    }
+
+    const openingStyles = startsAt.get(pos);
+    if (openingStyles) {
+      for (const span of openingStyles) {
+        const marker = styleMarkers[span.style];
+        if (!marker) continue;
+        stack.push(span);
+        out += marker.open;
+      }
+    }
+
+    const next = points[i + 1];
+    if (next === undefined) break;
+    if (next > pos) {
+      out += options.escapeText(text.slice(pos, next));
+    }
+  }
+
+  return out;
+}
diff --git a/src/signal/format.test.ts b/src/signal/format.test.ts
new file mode 100644
index 000000000..243234cb7
--- /dev/null
+++ b/src/signal/format.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+
+import { markdownToSignalText } from "./format.js";
+
+describe("markdownToSignalText", () => {
+  it("renders inline styles", () => {
+    const res = markdownToSignalText("hi _there_ **boss** ~~nope~~ `code`");
+
+    expect(res.text).toBe("hi there boss nope code");
+    expect(res.styles).toEqual([
+      { start: 3, length: 5, style: "ITALIC" },
+      { start: 9, length: 4, style: "BOLD" },
+      { start: 14, length: 4, style: "STRIKETHROUGH" },
+      { start: 19, length: 4, style: "MONOSPACE" },
+    ]);
+  });
+
+  it("renders links as label plus url when needed", () => {
+    const res = markdownToSignalText(
+      "see [docs](https://example.com) and https://example.com",
+    );
+
+    expect(res.text).toBe(
+      "see docs (https://example.com) and https://example.com",
+    );
+    expect(res.styles).toEqual([]);
+  });
+
+  it("applies spoiler styling", () => {
+    const res = markdownToSignalText("hello ||secret|| world");
+
+    expect(res.text).toBe("hello secret world");
+    expect(res.styles).toEqual([{ start: 6, length: 6, style: "SPOILER" }]);
+  });
+
+  it("renders fenced code blocks with monospaced styles", () => {
+    const res = markdownToSignalText(
+      "before\n\n```\nconst x = 1;\n```\n\nafter",
+    );
+
+    const prefix = "before\n\n";
+    const code = "const x = 1;\n";
+    const suffix = "\nafter";
+
+    expect(res.text).toBe(`${prefix}${code}${suffix}`);
+    expect(res.styles).toEqual([
+      { start: prefix.length, length: code.length, style: "MONOSPACE" },
+    ]);
+  });
+
+  it("renders lists without extra block markup", () => {
+    const res = markdownToSignalText("- one\n- two");
+
+    expect(res.text).toBe("• one\n• two");
+    expect(res.styles).toEqual([]);
+  });
+
+  it("uses UTF-16 code units for offsets", () => {
+    const res = markdownToSignalText("😀 **bold**");
+
+    const prefix = "😀 ";
+    expect(res.text).toBe(`${prefix}bold`);
+    expect(res.styles).toEqual([
+      { start: prefix.length, length: 4, style: "BOLD" },
+    ]);
+  });
+});
diff --git a/src/signal/format.ts b/src/signal/format.ts
new file mode 100644
index 000000000..122e04622
--- /dev/null
+++ b/src/signal/format.ts
@@ -0,0 +1,220 @@
+import { chunkMarkdownIR, markdownToIR, type MarkdownIR, type MarkdownStyle } from "../markdown/ir.js";
+
+type SignalTextStyle =
+  | "BOLD"
+  | "ITALIC"
+  | "STRIKETHROUGH"
+  | "MONOSPACE"
+  | "SPOILER";
+
+export type SignalTextStyleRange = {
+  start: number;
+  length: number;
+  style: SignalTextStyle;
+};
+
+export type SignalFormattedText = {
+  text: string;
+  styles: SignalTextStyleRange[];
+};
+
+type SignalStyleSpan = {
+  start: number;
+  end: number;
+  style: SignalTextStyle;
+};
+
+type Insertion = {
+  pos: number;
+  length: number;
+};
+
+function mapStyle(style: MarkdownStyle): SignalTextStyle | null {
+  switch (style) {
+    case "bold":
+      return "BOLD";
+    case "italic":
+      return "ITALIC";
+    case "strikethrough":
+      return "STRIKETHROUGH";
+    case "code":
+    case "code_block":
+      return "MONOSPACE";
+    case "spoiler":
+      return "SPOILER";
+    default:
+      return null;
+  }
+}
+
+function mergeStyles(styles: SignalTextStyleRange[]): SignalTextStyleRange[] {
+  const sorted = [...styles].sort((a, b) => {
+    if (a.start !== b.start) return a.start - b.start;
+    if (a.length !== b.length) return a.length - b.length;
+    return a.style.localeCompare(b.style);
+  });
+
+  const merged: SignalTextStyleRange[] = [];
+  for (const style of sorted) {
+    const prev = merged[merged.length - 1];
+    if (
+      prev &&
+      prev.style === style.style &&
+      style.start <= prev.start + prev.length
+    ) {
+      const prevEnd = prev.start + prev.length;
+      const nextEnd = Math.max(prevEnd, style.start + style.length);
+      prev.length = nextEnd - prev.start;
+      continue;
+    }
+    merged.push({ ...style });
+  }
+
+  return merged;
+}
+
+function clampStyles(
+  styles: SignalTextStyleRange[],
+  maxLength: number,
+): SignalTextStyleRange[] {
+  const clamped: SignalTextStyleRange[] = [];
+  for (const style of styles) {
+    const start = Math.max(0, Math.min(style.start, maxLength));
+    const end = Math.min(style.start + style.length, maxLength);
+    const length = end - start;
+    if (length > 0) clamped.push({ start, length, style: style.style });
+  }
+  return clamped;
+}
+
+function applyInsertionsToStyles(
+  spans: SignalStyleSpan[],
+  insertions: Insertion[],
+): SignalStyleSpan[] {
+  if (insertions.length === 0) return spans;
+  const sortedInsertions = [...insertions].sort((a, b) => a.pos - b.pos);
+  let updated = spans;
+
+  for (const insertion of sortedInsertions) {
+    const next: SignalStyleSpan[] = [];
+    for (const span of updated) {
+      if (span.end <= insertion.pos) {
+        next.push(span);
+        continue;
+      }
+      if (span.start >= insertion.pos) {
+        next.push({
+          start: span.start + insertion.length,
+          end: span.end + insertion.length,
+          style: span.style,
+        });
+        continue;
+      }
+      if (span.start < insertion.pos && span.end > insertion.pos) {
+        if (insertion.pos > span.start) {
+          next.push({
+            start: span.start,
+            end: insertion.pos,
+            style: span.style,
+          });
+        }
+        const shiftedStart = insertion.pos + insertion.length;
+        const shiftedEnd = span.end + insertion.length;
+        if (shiftedEnd > shiftedStart) {
+          next.push({
+            start: shiftedStart,
+            end: shiftedEnd,
+            style: span.style,
+          });
+        }
+      }
+    }
+    updated = next;
+  }
+
+  return updated;
+}
+
+function renderSignalText(ir: MarkdownIR): SignalFormattedText {
+  const text = ir.text ?? "";
+  if (!text) return { text: "", styles: [] };
+
+  const sortedLinks = [...ir.links].sort((a, b) => a.start - b.start);
+  let out = "";
+  let cursor = 0;
+  const insertions: Insertion[] = [];
+
+  for (const link of sortedLinks) {
+    if (link.start < cursor) continue;
+    out += text.slice(cursor, link.end);
+
+    const href = link.href.trim();
+    const label = text.slice(link.start, link.end);
+    const trimmedLabel = label.trim();
+    const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
+
+    if (href) {
+      if (!trimmedLabel) {
+        out += href;
+        insertions.push({ pos: link.end, length: href.length });
+      } else if (trimmedLabel !== href && trimmedLabel !== comparableHref) {
+        const addition = ` (${href})`;
+        out += addition;
+        insertions.push({ pos: link.end, length: addition.length });
+      }
+    }
+
+    cursor = link.end;
+  }
+
+  out += text.slice(cursor);
+
+  const mappedStyles: SignalStyleSpan[] = ir.styles
+    .map((span) => {
+      const mapped = mapStyle(span.style);
+      if (!mapped) return null;
+      return { start: span.start, end: span.end, style: mapped };
+    })
+    .filter((span): span is SignalStyleSpan => span !== null);
+
+  const adjusted = applyInsertionsToStyles(mappedStyles, insertions);
+  const trimmedText = out.trimEnd();
+  const trimmedLength = trimmedText.length;
+  const clamped = clampStyles(
+    adjusted.map((span) => ({
+      start: span.start,
+      length: span.end - span.start,
+      style: span.style,
+    })),
+    trimmedLength,
+  );
+
+  return {
+    text: trimmedText,
+    styles: mergeStyles(clamped),
+  };
+}
+
+export function markdownToSignalText(markdown: string): SignalFormattedText {
+  const ir = markdownToIR(markdown ?? "", {
+    linkify: true,
+    enableSpoilers: true,
+    headingStyle: "none",
+    blockquotePrefix: "",
+  });
+  return renderSignalText(ir);
+}
+
+export function markdownToSignalTextChunks(
+  markdown: string,
+  limit: number,
+): SignalFormattedText[] {
+  const ir = markdownToIR(markdown ?? "", {
+    linkify: true,
+    enableSpoilers: true,
+    headingStyle: "none",
+    blockquotePrefix: "",
+  });
+  const chunks = chunkMarkdownIR(ir, limit);
+  return chunks.map((chunk) => renderSignalText(chunk));
+}
diff --git a/src/signal/send.ts b/src/signal/send.ts
index 6c33f8cd3..03f96c4fb 100644
--- a/src/signal/send.ts
+++ b/src/signal/send.ts
@@ -4,6 +4,7 @@ import { saveMediaBuffer } from "../media/store.js";
 import { loadWebMedia } from "../web/media.js";
 import { resolveSignalAccount } from "./accounts.js";
 import { signalRpcRequest } from "./client.js";
+import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
 
 export type SignalSendOpts = {
   baseUrl?: string;
@@ -12,6 +13,8 @@ export type SignalSendOpts = {
   mediaUrl?: string;
   maxBytes?: number;
   timeoutMs?: number;
+  textMode?: "markdown" | "plain";
+  textStyles?: SignalTextStyleRange[];
 };
 
 export type SignalSendResult = {
@@ -75,6 +78,9 @@ export async function sendMessageSignal(
   const account = opts.account?.trim() || accountInfo.config.account?.trim();
   const target = parseTarget(to);
   let message = text ?? "";
+  let messageFromPlaceholder = false;
+  let textStyles: SignalTextStyleRange[] = [];
+  const textMode = opts.textMode ?? "markdown";
   const maxBytes = (() => {
     if (typeof opts.maxBytes === "number") return opts.maxBytes;
     if (typeof accountInfo.config.mediaMaxMb === "number") {
@@ -94,6 +100,17 @@ export async function sendMessageSignal(
     if (!message && kind) {
       // Avoid sending an empty body when only attachments exist.
       message = kind === "image" ? "" : ``;
+      messageFromPlaceholder = true;
+    }
+  }
+
+  if (message.trim() && !messageFromPlaceholder) {
+    if (textMode === "plain") {
+      textStyles = opts.textStyles ?? [];
+    } else {
+      const formatted = markdownToSignalText(message);
+      message = formatted.text;
+      textStyles = formatted.styles;
     }
   }
 
@@ -102,6 +119,11 @@ export async function sendMessageSignal(
   }
 
   const params: Record = { message };
+  if (textStyles.length > 0) {
+    params["text-style"] = textStyles.map(
+      (style) => `${style.start}:${style.length}:${style.style}`,
+    );
+  }
   if (account) params.account = account;
   if (attachments && attachments.length > 0) {
     params.attachments = attachments;
diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts
index 4a323af1f..6e090a7d7 100644
--- a/src/slack/format.test.ts
+++ b/src/slack/format.test.ts
@@ -33,9 +33,9 @@ describe("markdownToSlackMrkdwn", () => {
     expect(res).toBe("```\nconst x = 1;\n```");
   });
 
-  it("renders links with URL in parentheses", () => {
+  it("renders links with Slack mrkdwn syntax", () => {
     const res = markdownToSlackMrkdwn("see [docs](https://example.com)");
-    expect(res).toBe("see docs (https://example.com)");
+    expect(res).toBe("see ");
   });
 
   it("does not duplicate bare URLs", () => {
@@ -94,7 +94,7 @@ describe("markdownToSlackMrkdwn", () => {
       "**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
     );
     expect(res).toBe(
-      "*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second",
+      "*Important:* Check the _docs_ at \n\n• first\n• second",
     );
   });
 });
diff --git a/src/slack/format.ts b/src/slack/format.ts
index 39b9b2090..fddc014ec 100644
--- a/src/slack/format.ts
+++ b/src/slack/format.ts
@@ -1,33 +1,8 @@
-import MarkdownIt from "markdown-it";
+import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js";
+import { renderMarkdownWithMarkers } from "../markdown/render.js";
 
-type ListState = {
-  type: "bullet" | "ordered";
-  index: number;
-};
-
-type RenderEnv = {
-  slackListStack?: ListState[];
-  slackLinkStack?: { href: string }[];
-};
-
-const md = new MarkdownIt({
-  html: false,
-  // Slack will auto-link plain URLs; keeping linkify off avoids double-rendering
-  // (e.g. "https://x.com" becoming "https://x.com (https://x.com)").
-  linkify: false,
-  breaks: false,
-  typographer: false,
-});
-
-md.enable("strikethrough");
-
-/**
- * Escape special characters for Slack mrkdwn format.
- *
- * By default, Slack uses angle-bracket markup for mentions and links
- * (e.g. "<@U123>", ""). We preserve those tokens so agents
- * can intentionally include them, while escaping other uses of "<" and ">".
- */
+// Escape special characters for Slack mrkdwn format.
+// Preserve Slack's angle-bracket tokens so mentions and links stay intact.
 function escapeSlackMrkdwnSegment(text: string): string {
   return text.replace(/&/g, "&").replace(//g, ">");
 }
@@ -74,165 +49,63 @@ function escapeSlackMrkdwnText(text: string): string {
   return out.join("");
 }
 
-function getListStack(env: RenderEnv): ListState[] {
-  if (!env.slackListStack) env.slackListStack = [];
-  return env.slackListStack;
+function buildSlackLink(link: MarkdownLinkSpan, text: string) {
+  const href = link.href.trim();
+  if (!href) return null;
+  const label = text.slice(link.start, link.end);
+  const trimmedLabel = label.trim();
+  const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
+  const useMarkup =
+    trimmedLabel.length > 0 && trimmedLabel !== href && trimmedLabel !== comparableHref;
+  if (!useMarkup) return null;
+  const safeHref = escapeSlackMrkdwnSegment(href);
+  return {
+    start: link.start,
+    end: link.end,
+    open: `<${safeHref}|`,
+    close: ">",
+  };
 }
 
-function getLinkStack(env: RenderEnv): { href: string }[] {
-  if (!env.slackLinkStack) env.slackLinkStack = [];
-  return env.slackLinkStack;
-}
-
-md.renderer.rules.text = (tokens, idx) => escapeSlackMrkdwnText(tokens[idx]?.content ?? "");
-
-md.renderer.rules.softbreak = () => "\n";
-md.renderer.rules.hardbreak = () => "\n";
-
-md.renderer.rules.paragraph_open = () => "";
-md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
-  const stack = getListStack(env as RenderEnv);
-  return stack.length ? "" : "\n\n";
-};
-
-md.renderer.rules.heading_open = () => "*";
-md.renderer.rules.heading_close = () => "*\n\n";
-
-md.renderer.rules.blockquote_open = () => "> ";
-md.renderer.rules.blockquote_close = () => "\n";
-
-md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
-  return "";
-};
-md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).pop();
-  return "";
-};
-md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
-  const start = Number(tokens[idx]?.attrGet("start") ?? "1");
-  getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
-  return "";
-};
-md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).pop();
-  return "";
-};
-md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
-  const stack = getListStack(env as RenderEnv);
-  const top = stack[stack.length - 1];
-  if (!top) return "";
-  top.index += 1;
-  const indent = "  ".repeat(Math.max(0, stack.length - 1));
-  const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
-  return `${indent}${prefix}`;
-};
-md.renderer.rules.list_item_close = () => "\n";
-
-// Slack mrkdwn uses _text_ for italic (same as markdown)
-md.renderer.rules.em_open = () => "_";
-md.renderer.rules.em_close = () => "_";
-
-// Slack mrkdwn uses *text* for bold (single asterisk, not double)
-md.renderer.rules.strong_open = () => "*";
-md.renderer.rules.strong_close = () => "*";
-
-// Slack mrkdwn uses ~text~ for strikethrough (single tilde)
-md.renderer.rules.s_open = () => "~";
-md.renderer.rules.s_close = () => "~";
-
-md.renderer.rules.code_inline = (tokens, idx) =>
-  `\`${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\``;
-
-md.renderer.rules.code_block = (tokens, idx) =>
-  `\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
-
-md.renderer.rules.fence = (tokens, idx) =>
-  `\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
-
-md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
-  const href = tokens[idx]?.attrGet("href") ?? "";
-  const stack = getLinkStack(env as RenderEnv);
-  stack.push({ href });
-  return "";
-};
-md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
-  const stack = getLinkStack(env as RenderEnv);
-  const link = stack.pop();
-  if (link?.href) {
-    return ` (${escapeSlackMrkdwnSegment(link.href)})`;
-  }
-  return "";
-};
-
-md.renderer.rules.image = (tokens, idx) => {
-  const alt = tokens[idx]?.content ?? "";
-  return escapeSlackMrkdwnSegment(alt);
-};
-
-md.renderer.rules.html_block = (tokens, idx) =>
-  escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
-md.renderer.rules.html_inline = (tokens, idx) =>
-  escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
-
-md.renderer.rules.table_open = () => "";
-md.renderer.rules.table_close = () => "";
-md.renderer.rules.thead_open = () => "";
-md.renderer.rules.thead_close = () => "";
-md.renderer.rules.tbody_open = () => "";
-md.renderer.rules.tbody_close = () => "";
-md.renderer.rules.tr_open = () => "";
-md.renderer.rules.tr_close = () => "\n";
-md.renderer.rules.th_open = () => "";
-md.renderer.rules.th_close = () => "\t";
-md.renderer.rules.td_open = () => "";
-md.renderer.rules.td_close = () => "\t";
-
-md.renderer.rules.hr = () => "\n";
-
-function protectSlackAngleLinks(markdown: string): {
-  markdown: string;
-  tokens: string[];
-} {
-  const tokens: string[] = [];
-  const protectedMarkdown = (markdown ?? "").replace(
-    /<(?:https?:\/\/|mailto:|tel:|slack:\/\/)[^>\n]+>/g,
-    (match) => {
-      const id = tokens.length;
-      tokens.push(match);
-      return `⟦clawdbot-slacktok:${id}⟧`;
-    },
-  );
-  return { markdown: protectedMarkdown, tokens };
-}
-
-function restoreSlackAngleLinks(text: string, tokens: string[]): string {
-  let out = text;
-  for (let i = 0; i < tokens.length; i++) {
-    out = out.replaceAll(`⟦clawdbot-slacktok:${i}⟧`, tokens[i] ?? "");
-  }
-  return out;
-}
-
-/**
- * Convert standard Markdown to Slack mrkdwn format.
- *
- * Slack mrkdwn differences from standard Markdown:
- * - Bold: *text* (single asterisk, not double)
- * - Italic: _text_ (same)
- * - Strikethrough: ~text~ (single tilde)
- * - Code: `code` (same)
- * - Links:  or plain URL
- * - Escape &, <, > as &, <, >
- */
 export function markdownToSlackMrkdwn(markdown: string): string {
-  const env: RenderEnv = {};
-  const protectedLinks = protectSlackAngleLinks(markdown ?? "");
-  const rendered = md.render(protectedLinks.markdown, env);
-  const normalized = rendered
-    .replace(/[ \t]+\n/g, "\n")
-    .replace(/\t+\n/g, "\n")
-    .replace(/\n{3,}/g, "\n\n")
-    .trimEnd();
-  return restoreSlackAngleLinks(normalized, protectedLinks.tokens);
+  const ir = markdownToIR(markdown ?? "", {
+    linkify: false,
+    autolink: false,
+    headingStyle: "bold",
+    blockquotePrefix: "> ",
+  });
+  return renderMarkdownWithMarkers(ir, {
+    styleMarkers: {
+      bold: { open: "*", close: "*" },
+      italic: { open: "_", close: "_" },
+      strikethrough: { open: "~", close: "~" },
+      code: { open: "`", close: "`" },
+      code_block: { open: "```\n", close: "```" },
+    },
+    escapeText: escapeSlackMrkdwnText,
+    buildLink: buildSlackLink,
+  });
+}
+
+export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] {
+  const ir = markdownToIR(markdown ?? "", {
+    linkify: false,
+    autolink: false,
+    headingStyle: "bold",
+    blockquotePrefix: "> ",
+  });
+  const chunks = chunkMarkdownIR(ir, limit);
+  return chunks.map((chunk) =>
+    renderMarkdownWithMarkers(chunk, {
+      styleMarkers: {
+        bold: { open: "*", close: "*" },
+        italic: { open: "_", close: "_" },
+        strikethrough: { open: "~", close: "~" },
+        code: { open: "`", close: "`" },
+        code_block: { open: "```\n", close: "```" },
+      },
+      escapeText: escapeSlackMrkdwnText,
+      buildLink: buildSlackLink,
+    }),
+  );
 }
diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts
index 2f4c4ad7f..59c9d8164 100644
--- a/src/slack/monitor/replies.ts
+++ b/src/slack/monitor/replies.ts
@@ -1,8 +1,8 @@
-import { chunkMarkdownText } from "../../auto-reply/chunk.js";
 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 { RuntimeEnv } from "../../runtime.js";
+import { markdownToSlackMrkdwnChunks } from "../format.js";
 import { sendMessageSlack } from "../send.js";
 
 export async function deliverReplies(params: {
@@ -14,7 +14,6 @@ export async function deliverReplies(params: {
   textLimit: number;
   replyThreadTs?: string;
 }) {
-  const chunkLimit = Math.min(params.textLimit, 4000);
   for (const payload of params.replies) {
     const threadTs = payload.replyToId ?? params.replyThreadTs;
     const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -22,15 +21,13 @@ export async function deliverReplies(params: {
     if (!text && mediaList.length === 0) continue;
 
     if (mediaList.length === 0) {
-      for (const chunk of chunkMarkdownText(text, chunkLimit)) {
-        const trimmed = chunk.trim();
-        if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
-        await sendMessageSlack(params.target, trimmed, {
-          token: params.token,
-          threadTs,
-          accountId: params.accountId,
-        });
-      }
+      const trimmed = text.trim();
+      if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
+      await sendMessageSlack(params.target, trimmed, {
+        token: params.token,
+        threadTs,
+        accountId: params.accountId,
+      });
     } else {
       let first = true;
       for (const mediaUrl of mediaList) {
@@ -130,7 +127,7 @@ export async function deliverSlackSlashReplies(params: {
       .filter(Boolean)
       .join("\n");
     if (!combined) continue;
-    for (const chunk of chunkMarkdownText(combined, chunkLimit)) {
+    for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
       messages.push(chunk);
     }
   }
diff --git a/src/slack/send.ts b/src/slack/send.ts
index 265ed08cd..6058d8b2c 100644
--- a/src/slack/send.ts
+++ b/src/slack/send.ts
@@ -1,12 +1,12 @@
 import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
 
-import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
+import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
 import { loadConfig } from "../config/config.js";
 import { logVerbose } from "../globals.js";
 import { loadWebMedia } from "../web/media.js";
 import type { SlackTokenSource } from "./accounts.js";
 import { resolveSlackAccount } from "./accounts.js";
-import { markdownToSlackMrkdwn } from "./format.js";
+import { markdownToSlackMrkdwnChunks } from "./format.js";
 import { resolveSlackBotToken } from "./token.js";
 
 const SLACK_TEXT_LIMIT = 4000;
@@ -164,8 +164,7 @@ 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 slackFormatted = markdownToSlackMrkdwn(trimmedMessage);
-  const chunks = chunkMarkdownText(slackFormatted, chunkLimit);
+  const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit);
   const mediaMaxBytes =
     typeof account.config.mediaMaxMb === "number"
       ? account.config.mediaMaxMb * 1024 * 1024
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index b87191221..0f01bd41c 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -1,5 +1,5 @@
 import { type Bot, InputFile } from "grammy";
-import { chunkMarkdownText } from "../../auto-reply/chunk.js";
+import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
 import type { ReplyPayload } from "../../auto-reply/types.js";
 import type { ReplyToMode } from "../../config/config.js";
 import { danger, logVerbose } from "../../globals.js";
@@ -10,7 +10,6 @@ import { isGifMedia } from "../../media/mime.js";
 import { saveMediaBuffer } from "../../media/store.js";
 import type { RuntimeEnv } from "../../runtime.js";
 import { loadWebMedia } from "../../web/media.js";
-import { markdownToTelegramHtml } from "../format.js";
 import { resolveTelegramVoiceSend } from "../voice.js";
 import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
 import type { TelegramContext } from "./types.js";
@@ -42,11 +41,14 @@ export async function deliverReplies(params: {
         ? [reply.mediaUrl]
         : [];
     if (mediaList.length === 0) {
-      for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) {
-        await sendTelegramText(bot, chatId, chunk, runtime, {
+      const chunks = markdownToTelegramChunks(reply.text || "", textLimit);
+      for (const chunk of chunks) {
+        await sendTelegramText(bot, chatId, chunk.html, runtime, {
           replyToMessageId:
             replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
           messageThreadId,
+          textMode: "html",
+          plainText: chunk.text,
         });
         if (replyToId && !hasReplied) {
           hasReplied = true;
@@ -155,7 +157,12 @@ async function sendTelegramText(
   chatId: string,
   text: string,
   runtime: RuntimeEnv,
-  opts?: { replyToMessageId?: number; messageThreadId?: number },
+  opts?: {
+    replyToMessageId?: number;
+    messageThreadId?: number;
+    textMode?: "markdown" | "html";
+    plainText?: string;
+  },
 ): Promise {
   const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
   const baseParams: Record = {
@@ -164,7 +171,8 @@ async function sendTelegramText(
   if (threadParams) {
     baseParams.message_thread_id = threadParams.message_thread_id;
   }
-  const htmlText = markdownToTelegramHtml(text);
+  const textMode = opts?.textMode ?? "markdown";
+  const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
   try {
     const res = await bot.api.sendMessage(chatId, htmlText, {
       parse_mode: "HTML",
@@ -175,7 +183,8 @@ async function sendTelegramText(
     const errText = formatErrorMessage(err);
     if (PARSE_ERR_RE.test(errText)) {
       runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
-      const res = await bot.api.sendMessage(chatId, text, {
+      const fallbackText = opts?.plainText ?? text;
+      const res = await bot.api.sendMessage(chatId, fallbackText, {
         ...baseParams,
       });
       return res.message_id;
diff --git a/src/telegram/format.ts b/src/telegram/format.ts
index 9465e26e0..654b6da02 100644
--- a/src/telegram/format.ts
+++ b/src/telegram/format.ts
@@ -1,138 +1,68 @@
-import MarkdownIt from "markdown-it";
+import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan, type MarkdownIR } from "../markdown/ir.js";
+import { renderMarkdownWithMarkers } from "../markdown/render.js";
 
-type ListState = {
-  type: "bullet" | "ordered";
-  index: number;
+export type TelegramFormattedChunk = {
+  html: string;
+  text: string;
 };
 
-type RenderEnv = {
-  telegramListStack?: ListState[];
-  telegramLinkStack?: boolean[];
-};
-
-const md = new MarkdownIt({
-  html: false,
-  linkify: true,
-  breaks: false,
-  typographer: false,
-});
-
-md.enable("strikethrough");
-
-const { escapeHtml } = md.utils;
-
-function getListStack(env: RenderEnv): ListState[] {
-  if (!env.telegramListStack) env.telegramListStack = [];
-  return env.telegramListStack;
+function escapeHtml(text: string): string {
+  return text.replace(/&/g, "&").replace(//g, ">");
 }
 
-function getLinkStack(env: RenderEnv): boolean[] {
-  if (!env.telegramLinkStack) env.telegramLinkStack = [];
-  return env.telegramLinkStack;
+function escapeHtmlAttr(text: string): string {
+  return escapeHtml(text).replace(/"/g, """);
 }
 
-md.renderer.rules.text = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
+function buildTelegramLink(link: MarkdownLinkSpan, _text: string) {
+  const href = link.href.trim();
+  if (!href) return null;
+  if (link.start === link.end) return null;
+  const safeHref = escapeHtmlAttr(href);
+  return {
+    start: link.start,
+    end: link.end,
+    open: ``,
+    close: "",
+  };
+}
 
-md.renderer.rules.softbreak = () => "\n";
-md.renderer.rules.hardbreak = () => "\n";
-
-md.renderer.rules.paragraph_open = () => "";
-md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
-  const stack = getListStack(env as RenderEnv);
-  return stack.length ? "" : "\n\n";
-};
-
-md.renderer.rules.heading_open = () => "";
-md.renderer.rules.heading_close = () => "\n\n";
-
-md.renderer.rules.blockquote_open = () => "";
-md.renderer.rules.blockquote_close = () => "\n";
-
-md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
-  return "";
-};
-md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).pop();
-  return "";
-};
-md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
-  const start = Number(tokens[idx]?.attrGet("start") ?? "1");
-  getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
-  return "";
-};
-md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
-  getListStack(env as RenderEnv).pop();
-  return "";
-};
-md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
-  const stack = getListStack(env as RenderEnv);
-  const top = stack[stack.length - 1];
-  if (!top) return "";
-  top.index += 1;
-  const indent = "  ".repeat(Math.max(0, stack.length - 1));
-  const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
-  return `${indent}${prefix}`;
-};
-md.renderer.rules.list_item_close = () => "\n";
-
-md.renderer.rules.em_open = () => "";
-md.renderer.rules.em_close = () => "";
-md.renderer.rules.strong_open = () => "";
-md.renderer.rules.strong_close = () => "";
-md.renderer.rules.s_open = () => "";
-md.renderer.rules.s_close = () => "";
-
-md.renderer.rules.code_inline = (tokens, idx) =>
-  `${escapeHtml(tokens[idx]?.content ?? "")}`;
-md.renderer.rules.code_block = (tokens, idx) =>
-  `
${escapeHtml(tokens[idx]?.content ?? "")}
\n`; -md.renderer.rules.fence = (tokens, idx) => - `
${escapeHtml(tokens[idx]?.content ?? "")}
\n`; - -md.renderer.rules.link_open = (tokens, idx, _opts, env) => { - const href = tokens[idx]?.attrGet("href") ?? ""; - const safeHref = escapeHtml(href); - const stack = getLinkStack(env as RenderEnv); - const hasHref = Boolean(safeHref); - stack.push(hasHref); - return hasHref ? `` : ""; -}; -md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => { - const stack = getLinkStack(env as RenderEnv); - const hasHref = stack.pop(); - return hasHref ? "" : ""; -}; - -md.renderer.rules.image = (tokens, idx) => { - const alt = tokens[idx]?.content ?? ""; - return escapeHtml(alt); -}; - -md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); -md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); - -md.renderer.rules.table_open = () => ""; -md.renderer.rules.table_close = () => ""; -md.renderer.rules.thead_open = () => ""; -md.renderer.rules.thead_close = () => ""; -md.renderer.rules.tbody_open = () => ""; -md.renderer.rules.tbody_close = () => ""; -md.renderer.rules.tr_open = () => ""; -md.renderer.rules.tr_close = () => "\n"; -md.renderer.rules.th_open = () => ""; -md.renderer.rules.th_close = () => "\t"; -md.renderer.rules.td_open = () => ""; -md.renderer.rules.td_close = () => "\t"; - -md.renderer.rules.hr = () => "\n"; +function renderTelegramHtml(ir: MarkdownIR): string { + return renderMarkdownWithMarkers(ir, { + styleMarkers: { + bold: { open: "", close: "" }, + italic: { open: "", close: "" }, + strikethrough: { open: "", close: "" }, + code: { open: "", close: "" }, + code_block: { open: "
", close: "
" }, + }, + escapeText: escapeHtml, + buildLink: buildTelegramLink, + }); +} export function markdownToTelegramHtml(markdown: string): string { - const env: RenderEnv = {}; - const rendered = md.render(markdown ?? "", env); - return rendered - .replace(/[ \t]+\n/g, "\n") - .replace(/\t+\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd(); + const ir = markdownToIR(markdown ?? "", { + linkify: true, + headingStyle: "none", + blockquotePrefix: "", + }); + return renderTelegramHtml(ir); +} + +export function markdownToTelegramChunks(markdown: string, limit: number): TelegramFormattedChunk[] { + const ir = markdownToIR(markdown ?? "", { + linkify: true, + headingStyle: "none", + blockquotePrefix: "", + }); + const chunks = chunkMarkdownIR(ir, limit); + return chunks.map((chunk) => ({ + html: renderTelegramHtml(chunk), + text: chunk.text, + })); +} + +export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] { + return markdownToTelegramChunks(markdown, limit).map((chunk) => chunk.html); } diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 8e3bc5e01..f857fb239 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -28,6 +28,8 @@ type TelegramSendOpts = { maxBytes?: number; api?: Bot["api"]; retry?: RetryConfig; + textMode?: "markdown" | "html"; + plainText?: string; /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ asVoice?: boolean; /** Message ID to reply to (for threading) */ @@ -308,7 +310,8 @@ export async function sendMessageTelegram( if (!text || !text.trim()) { throw new Error("Message must be non-empty for Telegram sends"); } - const htmlText = markdownToTelegramHtml(text); + const textMode = opts.textMode ?? "markdown"; + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); const textParams = hasThreadParams ? { parse_mode: "HTML" as const, @@ -335,11 +338,12 @@ export async function sendMessageTelegram( ...(replyMarkup ? { reply_markup: replyMarkup } : {}), } : undefined; + const fallbackText = opts.plainText ?? text; return await request( () => plainParams - ? api.sendMessage(chatId, text, plainParams) - : api.sendMessage(chatId, text), + ? api.sendMessage(chatId, fallbackText, plainParams) + : api.sendMessage(chatId, fallbackText), "message-plain", ).catch((err2) => { throw wrapChatNotFound(err2);