refactor: unify markdown formatting pipeline
This commit is contained in:
34
docs/concepts/markdown-formatting.md
Normal file
34
docs/concepts/markdown-formatting.md
Normal file
@@ -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 `<url|label>`.
|
||||
- **Telegram:** HTML tags (`<b>`, `<i>`, `<s>`, `<code>`, `<pre><code>`, `<a href>`).
|
||||
- **Signal:** plain text + `text-style` ranges; links become `label (url)` when label differs.
|
||||
|
||||
## Link policy
|
||||
|
||||
- **Slack:** `[label](url)` -> `<url|label>`; bare URLs are left as-is.
|
||||
- **Telegram:** `[label](url)` -> `<a href="url">label</a>` (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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
496
src/markdown/ir.ts
Normal file
496
src/markdown/ir.ts
Normal file
@@ -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;
|
||||
}
|
||||
137
src/markdown/render.ts
Normal file
137
src/markdown/render.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
|
||||
|
||||
export type RenderStyleMarker = {
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
|
||||
|
||||
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<MarkdownStyle, number>(
|
||||
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<number>();
|
||||
boundaries.add(0);
|
||||
boundaries.add(text.length);
|
||||
|
||||
const startsAt = new Map<number, MarkdownStyleSpan[]>();
|
||||
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<number, RenderLink[]>();
|
||||
const linkEnds = new Map<number, RenderLink[]>();
|
||||
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;
|
||||
}
|
||||
67
src/signal/format.test.ts
Normal file
67
src/signal/format.test.ts
Normal file
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
220
src/signal/format.ts
Normal file
220
src/signal/format.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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" ? "<media:image>" : `<media:${kind}>`;
|
||||
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<string, unknown> = { 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;
|
||||
|
||||
@@ -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 <https://example.com|docs>");
|
||||
});
|
||||
|
||||
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 <https://example.com|link>\n\n• first\n• second",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>", "<https://…|text>"). 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, "<").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: <url|text> 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number | undefined> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
|
||||
const baseParams: Record<string, unknown> = {
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "<").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: `<a href="${safeHref}">`,
|
||||
close: "</a>",
|
||||
};
|
||||
}
|
||||
|
||||
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 = () => "<i>";
|
||||
md.renderer.rules.em_close = () => "</i>";
|
||||
md.renderer.rules.strong_open = () => "<b>";
|
||||
md.renderer.rules.strong_close = () => "</b>";
|
||||
md.renderer.rules.s_open = () => "<s>";
|
||||
md.renderer.rules.s_close = () => "</s>";
|
||||
|
||||
md.renderer.rules.code_inline = (tokens, idx) =>
|
||||
`<code>${escapeHtml(tokens[idx]?.content ?? "")}</code>`;
|
||||
md.renderer.rules.code_block = (tokens, idx) =>
|
||||
`<pre><code>${escapeHtml(tokens[idx]?.content ?? "")}</code></pre>\n`;
|
||||
md.renderer.rules.fence = (tokens, idx) =>
|
||||
`<pre><code>${escapeHtml(tokens[idx]?.content ?? "")}</code></pre>\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 ? `<a href="${safeHref}">` : "";
|
||||
};
|
||||
md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
|
||||
const stack = getLinkStack(env as RenderEnv);
|
||||
const hasHref = stack.pop();
|
||||
return hasHref ? "</a>" : "";
|
||||
};
|
||||
|
||||
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: "<b>", close: "</b>" },
|
||||
italic: { open: "<i>", close: "</i>" },
|
||||
strikethrough: { open: "<s>", close: "</s>" },
|
||||
code: { open: "<code>", close: "</code>" },
|
||||
code_block: { open: "<pre><code>", close: "</code></pre>" },
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user