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 { sendMessageTelegram } from "../../../telegram/send.js";
|
||||||
import type { ChannelOutboundAdapter } from "../types.js";
|
import type { ChannelOutboundAdapter } from "../types.js";
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ function parseReplyToMessageId(replyToId?: string | null) {
|
|||||||
|
|
||||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkMarkdownText,
|
chunker: markdownToTelegramHtmlChunks,
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
resolveTarget: ({ to }) => {
|
resolveTarget: ({ to }) => {
|
||||||
const trimmed = to?.trim();
|
const trimmed = to?.trim();
|
||||||
@@ -27,6 +27,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
textMode: "html",
|
||||||
messageThreadId: threadId ?? undefined,
|
messageThreadId: threadId ?? undefined,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -39,6 +40,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
textMode: "html",
|
||||||
messageThreadId: threadId ?? undefined,
|
messageThreadId: threadId ?? undefined,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { markdownToSignalTextChunks } from "../../signal/format.js";
|
||||||
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
|
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
|
||||||
|
|
||||||
describe("deliverOutboundPayloads", () => {
|
describe("deliverOutboundPayloads", () => {
|
||||||
@@ -22,7 +23,9 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
|
|
||||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||||
for (const call of sendTelegram.mock.calls) {
|
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).toHaveLength(2);
|
||||||
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
|
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
|
||||||
@@ -53,7 +56,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
expect.objectContaining({ accountId: "default", verbose: false, textMode: "html" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,11 +78,46 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
mediaUrl: "https://x.test/a.jpg",
|
mediaUrl: "https://x.test/a.jpg",
|
||||||
maxBytes: 2 * 1024 * 1024,
|
maxBytes: 2 * 1024 * 1024,
|
||||||
|
textMode: "plain",
|
||||||
|
textStyles: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(results[0]).toMatchObject({ channel: "signal", messageId: "s1" });
|
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 () => {
|
it("chunks WhatsApp text and returns all results", async () => {
|
||||||
const sendWhatsApp = vi
|
const sendWhatsApp = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.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 { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { sendMessageDiscord } from "../../discord/send.js";
|
import type { sendMessageDiscord } from "../../discord/send.js";
|
||||||
import type { sendMessageIMessage } from "../../imessage/send.js";
|
import type { sendMessageIMessage } from "../../imessage/send.js";
|
||||||
import 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 { sendMessageSlack } from "../../slack/send.js";
|
||||||
import type { sendMessageTelegram } from "../../telegram/send.js";
|
import type { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||||
@@ -154,6 +156,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
const accountId = params.accountId;
|
const accountId = params.accountId;
|
||||||
const deps = params.deps;
|
const deps = params.deps;
|
||||||
const abortSignal = params.abortSignal;
|
const abortSignal = params.abortSignal;
|
||||||
|
const sendSignal = params.deps?.sendSignal ?? sendMessageSignal;
|
||||||
const results: OutboundDeliveryResult[] = [];
|
const results: OutboundDeliveryResult[] = [];
|
||||||
const handler = await createChannelHandler({
|
const handler = await createChannelHandler({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -170,6 +173,16 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
fallbackLimit: handler.textChunkLimit,
|
fallbackLimit: handler.textChunkLimit,
|
||||||
})
|
})
|
||||||
: undefined;
|
: 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) => {
|
const sendTextChunks = async (text: string) => {
|
||||||
throwIfAborted(abortSignal);
|
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);
|
const normalizedPayloads = normalizeOutboundPayloads(payloads);
|
||||||
for (const payload of normalizedPayloads) {
|
for (const payload of normalizedPayloads) {
|
||||||
try {
|
try {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
params.onPayload?.(payload);
|
params.onPayload?.(payload);
|
||||||
if (payload.mediaUrls.length === 0) {
|
if (payload.mediaUrls.length === 0) {
|
||||||
await sendTextChunks(payload.text);
|
if (isSignalChannel) {
|
||||||
|
await sendSignalTextChunks(payload.text);
|
||||||
|
} else {
|
||||||
|
await sendTextChunks(payload.text);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +261,11 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
const caption = first ? payload.text : "";
|
const caption = first ? payload.text : "";
|
||||||
first = false;
|
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) {
|
} catch (err) {
|
||||||
if (!params.bestEffort) throw 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 { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveSignalAccount } from "./accounts.js";
|
import { resolveSignalAccount } from "./accounts.js";
|
||||||
import { signalRpcRequest } from "./client.js";
|
import { signalRpcRequest } from "./client.js";
|
||||||
|
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||||
|
|
||||||
export type SignalSendOpts = {
|
export type SignalSendOpts = {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -12,6 +13,8 @@ export type SignalSendOpts = {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
textMode?: "markdown" | "plain";
|
||||||
|
textStyles?: SignalTextStyleRange[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalSendResult = {
|
export type SignalSendResult = {
|
||||||
@@ -75,6 +78,9 @@ export async function sendMessageSignal(
|
|||||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||||
const target = parseTarget(to);
|
const target = parseTarget(to);
|
||||||
let message = text ?? "";
|
let message = text ?? "";
|
||||||
|
let messageFromPlaceholder = false;
|
||||||
|
let textStyles: SignalTextStyleRange[] = [];
|
||||||
|
const textMode = opts.textMode ?? "markdown";
|
||||||
const maxBytes = (() => {
|
const maxBytes = (() => {
|
||||||
if (typeof opts.maxBytes === "number") return opts.maxBytes;
|
if (typeof opts.maxBytes === "number") return opts.maxBytes;
|
||||||
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
||||||
@@ -94,6 +100,17 @@ export async function sendMessageSignal(
|
|||||||
if (!message && kind) {
|
if (!message && kind) {
|
||||||
// Avoid sending an empty body when only attachments exist.
|
// Avoid sending an empty body when only attachments exist.
|
||||||
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
|
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 };
|
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 (account) params.account = account;
|
||||||
if (attachments && attachments.length > 0) {
|
if (attachments && attachments.length > 0) {
|
||||||
params.attachments = attachments;
|
params.attachments = attachments;
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ describe("markdownToSlackMrkdwn", () => {
|
|||||||
expect(res).toBe("```\nconst x = 1;\n```");
|
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)");
|
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", () => {
|
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",
|
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
|
||||||
);
|
);
|
||||||
expect(res).toBe(
|
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 = {
|
// Escape special characters for Slack mrkdwn format.
|
||||||
type: "bullet" | "ordered";
|
// Preserve Slack's angle-bracket tokens so mentions and links stay intact.
|
||||||
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 ">".
|
|
||||||
*/
|
|
||||||
function escapeSlackMrkdwnSegment(text: string): string {
|
function escapeSlackMrkdwnSegment(text: string): string {
|
||||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
@@ -74,165 +49,63 @@ function escapeSlackMrkdwnText(text: string): string {
|
|||||||
return out.join("");
|
return out.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListStack(env: RenderEnv): ListState[] {
|
function buildSlackLink(link: MarkdownLinkSpan, text: string) {
|
||||||
if (!env.slackListStack) env.slackListStack = [];
|
const href = link.href.trim();
|
||||||
return env.slackListStack;
|
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 {
|
export function markdownToSlackMrkdwn(markdown: string): string {
|
||||||
const env: RenderEnv = {};
|
const ir = markdownToIR(markdown ?? "", {
|
||||||
const protectedLinks = protectSlackAngleLinks(markdown ?? "");
|
linkify: false,
|
||||||
const rendered = md.render(protectedLinks.markdown, env);
|
autolink: false,
|
||||||
const normalized = rendered
|
headingStyle: "bold",
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
blockquotePrefix: "> ",
|
||||||
.replace(/\t+\n/g, "\n")
|
});
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
return renderMarkdownWithMarkers(ir, {
|
||||||
.trimEnd();
|
styleMarkers: {
|
||||||
return restoreSlackAngleLinks(normalized, protectedLinks.tokens);
|
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 { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
import { markdownToSlackMrkdwnChunks } from "../format.js";
|
||||||
import { sendMessageSlack } from "../send.js";
|
import { sendMessageSlack } from "../send.js";
|
||||||
|
|
||||||
export async function deliverReplies(params: {
|
export async function deliverReplies(params: {
|
||||||
@@ -14,7 +14,6 @@ export async function deliverReplies(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
replyThreadTs?: string;
|
replyThreadTs?: string;
|
||||||
}) {
|
}) {
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
|
||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
const threadTs = payload.replyToId ?? params.replyThreadTs;
|
const threadTs = payload.replyToId ?? params.replyThreadTs;
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
@@ -22,15 +21,13 @@ export async function deliverReplies(params: {
|
|||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
const trimmed = text.trim();
|
||||||
const trimmed = chunk.trim();
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
await sendMessageSlack(params.target, trimmed, {
|
||||||
await sendMessageSlack(params.target, trimmed, {
|
token: params.token,
|
||||||
token: params.token,
|
threadTs,
|
||||||
threadTs,
|
accountId: params.accountId,
|
||||||
accountId: params.accountId,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
@@ -130,7 +127,7 @@ export async function deliverSlackSlashReplies(params: {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (!combined) continue;
|
if (!combined) continue;
|
||||||
for (const chunk of chunkMarkdownText(combined, chunkLimit)) {
|
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
|
||||||
messages.push(chunk);
|
messages.push(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
|
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 { loadConfig } from "../config/config.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import type { SlackTokenSource } from "./accounts.js";
|
import type { SlackTokenSource } from "./accounts.js";
|
||||||
import { resolveSlackAccount } from "./accounts.js";
|
import { resolveSlackAccount } from "./accounts.js";
|
||||||
import { markdownToSlackMrkdwn } from "./format.js";
|
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||||
import { resolveSlackBotToken } from "./token.js";
|
import { resolveSlackBotToken } from "./token.js";
|
||||||
|
|
||||||
const SLACK_TEXT_LIMIT = 4000;
|
const SLACK_TEXT_LIMIT = 4000;
|
||||||
@@ -164,8 +164,7 @@ export async function sendMessageSlack(
|
|||||||
const { channelId } = await resolveChannelId(client, recipient);
|
const { channelId } = await resolveChannelId(client, recipient);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||||
const slackFormatted = markdownToSlackMrkdwn(trimmedMessage);
|
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit);
|
||||||
const chunks = chunkMarkdownText(slackFormatted, chunkLimit);
|
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
typeof account.config.mediaMaxMb === "number"
|
typeof account.config.mediaMaxMb === "number"
|
||||||
? account.config.mediaMaxMb * 1024 * 1024
|
? account.config.mediaMaxMb * 1024 * 1024
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Bot, InputFile } from "grammy";
|
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 { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { ReplyToMode } from "../../config/config.js";
|
import type { ReplyToMode } from "../../config/config.js";
|
||||||
import { danger, logVerbose } from "../../globals.js";
|
import { danger, logVerbose } from "../../globals.js";
|
||||||
@@ -10,7 +10,6 @@ import { isGifMedia } from "../../media/mime.js";
|
|||||||
import { saveMediaBuffer } from "../../media/store.js";
|
import { saveMediaBuffer } from "../../media/store.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
import { markdownToTelegramHtml } from "../format.js";
|
|
||||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||||
import type { TelegramContext } from "./types.js";
|
import type { TelegramContext } from "./types.js";
|
||||||
@@ -42,11 +41,14 @@ export async function deliverReplies(params: {
|
|||||||
? [reply.mediaUrl]
|
? [reply.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) {
|
const chunks = markdownToTelegramChunks(reply.text || "", textLimit);
|
||||||
await sendTelegramText(bot, chatId, chunk, runtime, {
|
for (const chunk of chunks) {
|
||||||
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||||
replyToMessageId:
|
replyToMessageId:
|
||||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
|
textMode: "html",
|
||||||
|
plainText: chunk.text,
|
||||||
});
|
});
|
||||||
if (replyToId && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
@@ -155,7 +157,12 @@ async function sendTelegramText(
|
|||||||
chatId: string,
|
chatId: string,
|
||||||
text: string,
|
text: string,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
opts?: { replyToMessageId?: number; messageThreadId?: number },
|
opts?: {
|
||||||
|
replyToMessageId?: number;
|
||||||
|
messageThreadId?: number;
|
||||||
|
textMode?: "markdown" | "html";
|
||||||
|
plainText?: string;
|
||||||
|
},
|
||||||
): Promise<number | undefined> {
|
): Promise<number | undefined> {
|
||||||
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
|
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
|
||||||
const baseParams: Record<string, unknown> = {
|
const baseParams: Record<string, unknown> = {
|
||||||
@@ -164,7 +171,8 @@ async function sendTelegramText(
|
|||||||
if (threadParams) {
|
if (threadParams) {
|
||||||
baseParams.message_thread_id = threadParams.message_thread_id;
|
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 {
|
try {
|
||||||
const res = await bot.api.sendMessage(chatId, htmlText, {
|
const res = await bot.api.sendMessage(chatId, htmlText, {
|
||||||
parse_mode: "HTML",
|
parse_mode: "HTML",
|
||||||
@@ -175,7 +183,8 @@ async function sendTelegramText(
|
|||||||
const errText = formatErrorMessage(err);
|
const errText = formatErrorMessage(err);
|
||||||
if (PARSE_ERR_RE.test(errText)) {
|
if (PARSE_ERR_RE.test(errText)) {
|
||||||
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${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,
|
...baseParams,
|
||||||
});
|
});
|
||||||
return res.message_id;
|
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 = {
|
export type TelegramFormattedChunk = {
|
||||||
type: "bullet" | "ordered";
|
html: string;
|
||||||
index: number;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RenderEnv = {
|
function escapeHtml(text: string): string {
|
||||||
telegramListStack?: ListState[];
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
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 getLinkStack(env: RenderEnv): boolean[] {
|
function escapeHtmlAttr(text: string): string {
|
||||||
if (!env.telegramLinkStack) env.telegramLinkStack = [];
|
return escapeHtml(text).replace(/"/g, """);
|
||||||
return env.telegramLinkStack;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
function renderTelegramHtml(ir: MarkdownIR): string {
|
||||||
md.renderer.rules.hardbreak = () => "\n";
|
return renderMarkdownWithMarkers(ir, {
|
||||||
|
styleMarkers: {
|
||||||
md.renderer.rules.paragraph_open = () => "";
|
bold: { open: "<b>", close: "</b>" },
|
||||||
md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
|
italic: { open: "<i>", close: "</i>" },
|
||||||
const stack = getListStack(env as RenderEnv);
|
strikethrough: { open: "<s>", close: "</s>" },
|
||||||
return stack.length ? "" : "\n\n";
|
code: { open: "<code>", close: "</code>" },
|
||||||
};
|
code_block: { open: "<pre><code>", close: "</code></pre>" },
|
||||||
|
},
|
||||||
md.renderer.rules.heading_open = () => "";
|
escapeText: escapeHtml,
|
||||||
md.renderer.rules.heading_close = () => "\n\n";
|
buildLink: buildTelegramLink,
|
||||||
|
});
|
||||||
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";
|
|
||||||
|
|
||||||
export function markdownToTelegramHtml(markdown: string): string {
|
export function markdownToTelegramHtml(markdown: string): string {
|
||||||
const env: RenderEnv = {};
|
const ir = markdownToIR(markdown ?? "", {
|
||||||
const rendered = md.render(markdown ?? "", env);
|
linkify: true,
|
||||||
return rendered
|
headingStyle: "none",
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
blockquotePrefix: "",
|
||||||
.replace(/\t+\n/g, "\n")
|
});
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
return renderTelegramHtml(ir);
|
||||||
.trimEnd();
|
}
|
||||||
|
|
||||||
|
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;
|
maxBytes?: number;
|
||||||
api?: Bot["api"];
|
api?: Bot["api"];
|
||||||
retry?: RetryConfig;
|
retry?: RetryConfig;
|
||||||
|
textMode?: "markdown" | "html";
|
||||||
|
plainText?: string;
|
||||||
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
||||||
asVoice?: boolean;
|
asVoice?: boolean;
|
||||||
/** Message ID to reply to (for threading) */
|
/** Message ID to reply to (for threading) */
|
||||||
@@ -308,7 +310,8 @@ export async function sendMessageTelegram(
|
|||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
throw new Error("Message must be non-empty for Telegram sends");
|
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
|
const textParams = hasThreadParams
|
||||||
? {
|
? {
|
||||||
parse_mode: "HTML" as const,
|
parse_mode: "HTML" as const,
|
||||||
@@ -335,11 +338,12 @@ export async function sendMessageTelegram(
|
|||||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const fallbackText = opts.plainText ?? text;
|
||||||
return await request(
|
return await request(
|
||||||
() =>
|
() =>
|
||||||
plainParams
|
plainParams
|
||||||
? api.sendMessage(chatId, text, plainParams)
|
? api.sendMessage(chatId, fallbackText, plainParams)
|
||||||
: api.sendMessage(chatId, text),
|
: api.sendMessage(chatId, fallbackText),
|
||||||
"message-plain",
|
"message-plain",
|
||||||
).catch((err2) => {
|
).catch((err2) => {
|
||||||
throw wrapChatNotFound(err2);
|
throw wrapChatNotFound(err2);
|
||||||
|
|||||||
Reference in New Issue
Block a user