mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(gateway): render agent Markdown in Slack row bodies, with native lists (#2316)
A queue row's body written in Markdown now renders in Slack instead of showing raw **bold**, ## headings, [text](url) links and hyphen lists. Bold, heading lines, links (including bare URLs in list items), code spans and fences render as Slack mrkdwn, and lists become native Slack rich_text list blocks, with nesting and ordered numbering, falling back to text at the block limit. A hand-written Slack link <url|text> is kept as a link. Mentions and Slack control syntax stay literal text, unsafe links aren't linked, and secret redaction runs as before. A fenced code block split across several messages is closed and reopened in each part, with room reserved for the delimiters. Source +210/-8, tests +252.
This commit is contained in:
1 parent
660598e641
commit
cd8f8b4f08
3 files changed
+462
-8
No files matched your search
@@ -29,6 +29,8 @@ export interface QitemLike {
|
||||
/** #1013 — the structured evidenceRef: for a parked gate it rides the evidence link/media
|
||||
* path when it is an https ref, else it renders as one Evidence line (never the packet). */
|
||||
evidenceRef?: string | null;
|
||||
/** The code fence an earlier part of a split brief left open; this body starts inside it. */
|
||||
bodyOpenFence?: string | null;
|
||||
}
|
||||
|
||||
/** #1013 — a workflow gate row parked on a human seat (the projector parks it
|
||||
@@ -220,6 +222,196 @@ function inert(text: string): string {
|
||||
return escapeSlackText(redactSecrets(text));
|
||||
}
|
||||
|
||||
/** Split a matched bare URL from what follows it in the sentence: trailing punctuation, a
|
||||
* `)` or `]` with no opening partner inside the URL, and closing `**` or unbalanced `__`
|
||||
* markers. `…/Function_(mathematics)` and `#object.__init__` stay whole. */
|
||||
function trimBareUrl(match: string): [url: string, rest: string] {
|
||||
const count = (s: string, part: string) => s.split(part).length - 1;
|
||||
let url = match;
|
||||
for (;;) {
|
||||
if (url.endsWith("**")) url = url.slice(0, -2);
|
||||
else if (url.endsWith("__") && count(url, "__") % 2 === 1) url = url.slice(0, -2);
|
||||
else if (/[.,;:!?]$/.test(url)) url = url.slice(0, -1);
|
||||
else if (url.endsWith(")") && count(url, ")") > count(url, "(")) url = url.slice(0, -1);
|
||||
else if (url.endsWith("]") && count(url, "]") > count(url, "[")) url = url.slice(0, -1);
|
||||
else return [url, match.slice(url.length)];
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one non-fenced, already-redacted line of Markdown to inert Slack mrkdwn. The line is
|
||||
* escaped first, so it holds no raw `<` or `>`, and `<n>` can mark a code span or link set aside
|
||||
* without colliding with input. A Markdown `[text](url)` or a Slack `<url|text>` / `<url>` becomes
|
||||
* a live link only for a safe https URL without `<`, `>` or `|` and a label (code included)
|
||||
* without `|`; otherwise it stays escaped text. */
|
||||
function inlineMarkdownToMrkdwn(line: string): string {
|
||||
const held: string[] = [];
|
||||
const hold = (rendered: string) => `<${held.push(rendered) - 1}>`;
|
||||
const restore = (s: string) => s.replace(/<(\d+)>/g, (_, i: string) => held[Number(i)] ?? "");
|
||||
const link = (original: string, url: string, label?: string) => {
|
||||
const text = label === undefined ? undefined : restore(label);
|
||||
const raw = url.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
||||
if (!isSafeHttpsUrl(raw) || /[<>|]/.test(raw) || text?.includes("|")) return original;
|
||||
return hold(text === undefined ? `<${url}>` : `<${url}|${text}>`);
|
||||
};
|
||||
return restore(escapeSlackText(line)
|
||||
.replace(/`[^`\n]+`/g, hold)
|
||||
.replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (m, label: string, url: string) => link(m, url, label))
|
||||
.replace(/<(https:\/\/[^\s|]+?)(?:\|(.+?))?>/g, (m, url: string, label?: string) => link(m, url, label))
|
||||
// A bare URL is held as written: Slack links it, and `__` or `**` inside it is not bold.
|
||||
.replace(/https:\/\/[^\s<>|`]+/g, (m) => {
|
||||
const [url, rest] = trimBareUrl(m);
|
||||
return hold(url) + rest;
|
||||
})
|
||||
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, "*$1*")
|
||||
.replace(/__(?=\S)(.+?)(?<=\S)__/g, "*$1*"));
|
||||
}
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/** One pass over the lines: the converted text, and the fence still open at its end ("" if none). */
|
||||
function convertMarkdown(text: string, openFence: string): { text: string; openFence: string } {
|
||||
let fence = openFence;
|
||||
const lines = redactSecrets(text).split("\n").map((line) => {
|
||||
const marker = FENCE_PATTERN.exec(line)?.[1];
|
||||
if (fence) {
|
||||
if (marker && marker[0] === fence[0] && marker.length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) fence = "";
|
||||
return escapeSlackText(line);
|
||||
}
|
||||
if (marker) {
|
||||
fence = marker;
|
||||
return escapeSlackText(line);
|
||||
}
|
||||
const heading = /^#{1,6}\s+(.*?)(?:\s+#+)?\s*$/.exec(line);
|
||||
if (heading) {
|
||||
if (!heading[1]) return "";
|
||||
const inner = inlineMarkdownToMrkdwn(heading[1]);
|
||||
return /^\*[^*\n]+\*$/.test(inner) ? inner : `*${inner}*`;
|
||||
}
|
||||
// Section mrkdwn has no list syntax; a plain "- " reads better than a typed bullet character.
|
||||
const item = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||
if (item) {
|
||||
const [, indent = "", rest = ""] = item;
|
||||
return `${indent}- ${inlineMarkdownToMrkdwn(rest)}`;
|
||||
}
|
||||
return inlineMarkdownToMrkdwn(line);
|
||||
});
|
||||
return { text: lines.join("\n"), openFence: fence };
|
||||
}
|
||||
|
||||
/** Agent-written Markdown as Slack mrkdwn, through the same inert pipeline as every other
|
||||
* queue-controlled field (secrets redacted, then &, <, > escaped). `**b**`/`__b__` become
|
||||
* `*b*`, `#` headings a bold line, `-`/`*`/`+` list items `- ` (indent kept), and safe https
|
||||
* `[text](url)` and `<url|text>` links live links. A list marker needs a following space, so
|
||||
* `*bold*` is not a list item. Code spans and ``` or ~~~ fenced blocks are only escaped. `openFence` is the
|
||||
* fence a split brief's earlier part left open (see openFenceAfter). */
|
||||
export function markdownToSlackMrkdwn(text: string, openFence = ""): string {
|
||||
const converted = convertMarkdown(text, openFence);
|
||||
// Slack knows only ``` blocks, and only inside one message: a part that starts or ends inside one is wrapped to match.
|
||||
const head = openFence.startsWith("`") ? "```\n" : "";
|
||||
const tail = converted.openFence.startsWith("`") ? "\n```" : "";
|
||||
return `${head}${converted.text}${tail}`;
|
||||
}
|
||||
|
||||
/** Room a split part needs for the ``` lines markdownToSlackMrkdwn may add around it. */
|
||||
export const CODE_FENCE_ROOM = 8;
|
||||
|
||||
/** The fence still open after `text`, so the next part of a split brief continues inside it. */
|
||||
export function openFenceAfter(text: string, openFence = ""): string {
|
||||
return convertMarkdown(text, openFence).openFence;
|
||||
}
|
||||
|
||||
type RichInline =
|
||||
| { type: "text"; text: string; style?: { bold?: boolean; code?: boolean } }
|
||||
| { type: "link"; url: string; text?: string };
|
||||
|
||||
// Code spans, Markdown links, Slack links, bare https URLs, then **bold**, __bold__ and *bold*.
|
||||
const RICH_INLINE = /`([^`\n]+)`|\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)|<(https:\/\/[^\s|>]+)(?:\|([^>\n]+))?>|(https:\/\/[^\s<>|`]+)|\*\*(?=\S)(.+?)(?<=\S)\*\*|__(?=\S)(.+?)(?<=\S)__|\*(?=\S)([^*\n]+?)(?<=\S)\*/g;
|
||||
|
||||
/** One already-redacted list item as rich text elements. Rich text is never parsed for
|
||||
* mentions or links, so plain text needs no escaping; a link element is emitted only for a
|
||||
* URL that passes the same checks as a mrkdwn link. */
|
||||
function richInline(text: string): RichInline[] {
|
||||
const out: RichInline[] = [];
|
||||
const plain = (t: string) => {
|
||||
const last = out.at(-1);
|
||||
if (last?.type === "text" && !last.style) last.text += t;
|
||||
else if (t) out.push({ type: "text", text: t });
|
||||
};
|
||||
const re = new RegExp(RICH_INLINE.source, "g");
|
||||
let at = 0;
|
||||
for (let m = re.exec(text); m; m = re.exec(text)) {
|
||||
plain(text.slice(at, m.index));
|
||||
const [all, code, mdLabel, mdUrl, slackUrl, slackLabel, bare, b1, b2, b3] = m;
|
||||
const url = mdUrl ?? slackUrl;
|
||||
const label = mdLabel ?? slackLabel;
|
||||
const bold = b1 ?? b2 ?? b3;
|
||||
at = m.index + all.length;
|
||||
if (code !== undefined) out.push({ type: "text", text: code, style: { code: true } });
|
||||
else if (bare !== undefined) {
|
||||
const [target] = trimBareUrl(bare);
|
||||
if (isSafeHttpsUrl(target)) out.push({ type: "link", url: target });
|
||||
else plain(target);
|
||||
// What follows the URL (closing markers included) is parsed again.
|
||||
at = re.lastIndex = m.index + target.length;
|
||||
} else if (bold !== undefined) {
|
||||
// Bold content is parsed again, so a link inside stays a link and the text around it is bold.
|
||||
for (const el of richInline(bold)) out.push(el.type === "text" ? { ...el, style: { ...el.style, bold: true } } : el);
|
||||
} else if (url !== undefined && isSafeHttpsUrl(url) && !/[<>|]/.test(url)) out.push(label ? { type: "link", url, text: label } : { type: "link", url });
|
||||
else plain(all);
|
||||
}
|
||||
plain(text.slice(at));
|
||||
return out.length ? out : [{ type: "text", text: " " }];
|
||||
}
|
||||
|
||||
const LIST_ITEM = /^(\s*)(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/;
|
||||
// ponytail: Slack documents no indent maximum; 4 covers real briefs and keeps clear of invalid_blocks.
|
||||
const MAX_LIST_INDENT = 4;
|
||||
|
||||
/** The body as blocks: runs of list items (outside code fences) become one native `rich_text`
|
||||
* list block each; everything else stays mrkdwn sections. Nesting is two spaces per level. */
|
||||
export function buildBodyBlocks(text: string, openFence = ""): unknown[] {
|
||||
type List = { type: "rich_text_list"; style: "bullet" | "ordered"; indent: number; offset?: number; elements: unknown[] };
|
||||
const blocks: unknown[] = [];
|
||||
let fence = openFence;
|
||||
let runFence = openFence;
|
||||
let run: string[] = [];
|
||||
let lists: List[] = [];
|
||||
const flushText = () => {
|
||||
const source = run.join("\n");
|
||||
run = [];
|
||||
if (!source.trim()) return;
|
||||
const converted = markdownToSlackMrkdwn(source, runFence);
|
||||
if (converted.trim()) blocks.push({ type: "section", text: { type: "mrkdwn", text: bounded(converted, SLACK_SECTION_CAP, "body") } });
|
||||
};
|
||||
const flushLists = () => {
|
||||
if (lists.length) blocks.push({ type: "rich_text", elements: lists });
|
||||
lists = [];
|
||||
};
|
||||
for (const line of redactSecrets(text).split("\n")) {
|
||||
const item = fence ? null : LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
flushText();
|
||||
const [, lead = "", bullet, number, rest = ""] = item;
|
||||
const style = bullet ? "bullet" : "ordered";
|
||||
const indent = Math.min(MAX_LIST_INDENT, Math.floor(lead.replaceAll("\t", " ").length / 2));
|
||||
const section = { type: "rich_text_section", elements: richInline(rest) };
|
||||
const last = lists.at(-1);
|
||||
if (last && last.style === style && last.indent === indent) last.elements.push(section);
|
||||
// An ordered run keeps its own numbering (a list starting at 5, or resuming after a nested list or a split).
|
||||
else if (number && Number(number) > 1) lists.push({ type: "rich_text_list", style, indent, offset: Number(number) - 1, elements: [section] });
|
||||
else lists.push({ type: "rich_text_list", style, indent, elements: [section] });
|
||||
continue;
|
||||
}
|
||||
flushLists();
|
||||
if (!run.length) runFence = fence;
|
||||
run.push(line);
|
||||
fence = openFenceAfter(line, fence);
|
||||
}
|
||||
flushText();
|
||||
flushLists();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function isSafeHttpsUrl(url: string): boolean {
|
||||
if (!/^https:\/\/\S+$/.test(url) || containsSecret(url)) return false;
|
||||
try {
|
||||
@@ -389,7 +581,7 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
|
||||
? (!gateEvidence || gateEvidenceCarried
|
||||
? ""
|
||||
: bounded(`Evidence: ${inert(gateEvidence)}`, SLACK_SECTION_CAP, "body"))
|
||||
: bounded(inert(String(q.body || "")), SLACK_SECTION_CAP, "body");
|
||||
: bounded(markdownToSlackMrkdwn(String(q.body || ""), q.bodyOpenFence ?? ""), SLACK_SECTION_CAP, "body");
|
||||
const mention = opts.mentionUserId ? `<@${opts.mentionUserId}> :rotating_light: ` : "";
|
||||
const headline = bounded(`${mention}*${summary}*`, SLACK_SECTION_CAP, "subject");
|
||||
const machine = opts.attribution && !opts.attribution.host && opts.machine ? ` on ${inert(opts.machine)}` : "";
|
||||
@@ -419,11 +611,16 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
|
||||
const repeatBlock = shownRepeat ? { type: "context", elements: [{ type: "mrkdwn", text: shownRepeat }] } : null;
|
||||
if (repeatBlock) blocks.push(repeatBlock);
|
||||
if (shownContext) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: shownContext }] });
|
||||
if (body.trim()) blocks.push({ type: "section", text: { type: "mrkdwn", text: body } });
|
||||
const bodySection = { type: "section", text: { type: "mrkdwn", text: body } };
|
||||
const bodyBlocks = !body.trim() ? [] : humanGate ? [bodySection] : buildBodyBlocks(String(q.body || ""), q.bodyOpenFence ?? "");
|
||||
const bodyAt = blocks.length;
|
||||
blocks.push(...bodyBlocks);
|
||||
if (questionParts) blocks.push(...questionParts.blocks);
|
||||
if (replyHint) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: replyHint }] });
|
||||
blocks.push(...imageBlocks);
|
||||
if (evidence) blocks.push(evidence.block);
|
||||
// A body split into many list and text runs falls back to one mrkdwn section rather than refusing the message.
|
||||
if (blocks.length > 50 && bodyBlocks.length > 1) blocks.splice(bodyAt, bodyBlocks.length, bodySection);
|
||||
if (repeatBlock && blocks.length > 50) blocks.splice(blocks.indexOf(repeatBlock), 1);
|
||||
if (blocks.length > 50) throw new HumanMessageShapeError("Message exceeds 50 Slack blocks. Reduce attachments before sending.");
|
||||
return { text, blocks };
|
||||
|
||||
@@ -18,7 +18,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { postChatMessage, getUploadURLExternal, uploadBytesExternal, completeUploadExternal, fetchRecentMessageTexts, type FetchImpl } from "./slack-api.js";
|
||||
import {
|
||||
buildOutboundMessage, attributionFromSession, reconcileToken, redactSecrets, splitForSlack,
|
||||
buildOutboundMessage, attributionFromSession, CODE_FENCE_ROOM, openFenceAfter, reconcileToken, redactSecrets, splitForSlack,
|
||||
HumanMessageShapeError, SLACK_SECTION_CAP, SLACK_TEXT_CAP, type SlackMediaRef,
|
||||
} from "./message.js";
|
||||
import type { SeenStore } from "./state-store.js";
|
||||
@@ -241,7 +241,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
log(`delivery: decision ${decision.decisionId} already delivered — re-ack, no re-post`);
|
||||
return { ok: true };
|
||||
}
|
||||
const q = (decision.payload ?? {}) as OutboundPostPayload & { media?: SlackMediaRef[] };
|
||||
const q = (decision.payload ?? {}) as DeliveryPart;
|
||||
// #192: the channel first, from this post's record when it has one, so the thread below is
|
||||
// chosen inside the channel the post actually goes to (and went to, on a retry).
|
||||
const pinned = firstAttemptChannel(opts, opts.attempted.load(), decision.decisionId);
|
||||
@@ -302,6 +302,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
state: q.state,
|
||||
tags: q.tags,
|
||||
evidenceRef: q.evidenceRef,
|
||||
bodyOpenFence: q.bodyOpenFence,
|
||||
},
|
||||
{
|
||||
sourceLabel: opts.sourceLabel,
|
||||
@@ -531,7 +532,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
};
|
||||
}
|
||||
|
||||
type DeliveryPart = OutboundPostPayload & { media?: SlackMediaRef[] };
|
||||
type DeliveryPart = OutboundPostPayload & { media?: SlackMediaRef[]; bodyOpenFence?: string };
|
||||
|
||||
/** #897 — the most Slack messages one ask may post: the primary plus its thread replies. */
|
||||
export const MAX_HUMAN_MESSAGE_PARTS = 20;
|
||||
@@ -609,7 +610,7 @@ function splitIntoParts(opts: SubsystemSlackDeliveryOpts, q: DeliveryPart, decis
|
||||
// A part's room is what its subject, options, evidence, sender and marker leave of both limits.
|
||||
const roomFor = (part: DeliveryPart, index: number, reserve = 0): number => {
|
||||
const frame = renderPart(opts, q, { ...part, body: "" }, index, partIdFor(decisionId, 2, index));
|
||||
return Math.min(SLACK_SECTION_CAP, SLACK_TEXT_CAP - frame.text.length - 1) - reserve;
|
||||
return Math.min(SLACK_SECTION_CAP, SLACK_TEXT_CAP - frame.text.length - 1) - reserve - CODE_FENCE_ROOM;
|
||||
};
|
||||
const primary: DeliveryPart = { ...q, humanDetail: undefined };
|
||||
// A retry replays its first attempt's cuts, so no piece moves between the parts already posted and the rest.
|
||||
@@ -623,10 +624,14 @@ function splitIntoParts(opts: SubsystemSlackDeliveryOpts, q: DeliveryPart, decis
|
||||
// A brief that fits keeps the usual note; only a brief that continues says so.
|
||||
const note = body.length > 1 ? CONTINUES_NOTE : DETAIL_NOTE;
|
||||
const parts: DeliveryPart[] = [{ ...primary, body: body[0] ? `${body[0]}\n\n${note}` : note }];
|
||||
for (let k = 1; k < body.length; k++) parts.push({ ...reply(`Continued (${k + 1} of ${body.length})`), body: body[k] });
|
||||
// A piece cut inside a code fence keeps rendering as code in the next part.
|
||||
const fences = (pieces: string[]) => { let open = ""; return pieces.map((piece) => { const start = open; open = openFenceAfter(piece, open); return start; }); };
|
||||
const bodyFences = fences(body);
|
||||
for (let k = 1; k < body.length; k++) parts.push({ ...reply(`Continued (${k + 1} of ${body.length})`), body: body[k], bodyOpenFence: bodyFences[k] });
|
||||
// #1013 — a detail piece carries the human's own content, never the agent packet, so the
|
||||
// parked-gate state must not ride it (the gate check would eat its body like the packet's).
|
||||
detail.forEach((piece, j) => parts.push({ ...reply(`Supplemental detail (${j + 1} of ${detail.length})`), body: piece, state: null }));
|
||||
const detailFences = fences(detail);
|
||||
detail.forEach((piece, j) => parts.push({ ...reply(`Supplemental detail (${j + 1} of ${detail.length})`), body: piece, state: null, bodyOpenFence: detailFences[j] }));
|
||||
if (parts.length > MAX_HUMAN_MESSAGE_PARTS) {
|
||||
throw new HumanMessageShapeError(`This brief needs ${parts.length} Slack messages (maximum ${MAX_HUMAN_MESSAGE_PARTS}). Shorten it, or put the long part in a file and link it.`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildBodyBlocks, buildOutboundMessage, containsSecret, escapeSlackText, markdownToSlackMrkdwn, openFenceAfter, splitForSlack } from "../src/domain/gateway/slack/message.js";
|
||||
import { subsystemSlackDeliver } from "../src/domain/gateway/slack/slack-delivery.js";
|
||||
import { SeenStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
import type { OutboundDecision } from "../src/domain/gateway/protocol.js";
|
||||
|
||||
const md = markdownToSlackMrkdwn;
|
||||
type Item = Array<Record<string, unknown>>;
|
||||
const list = (lists: Array<{ style: string; indent: number; offset?: number; items: Item[] }>) => ({
|
||||
type: "rich_text",
|
||||
elements: lists.map(({ style, indent, offset, items }) => ({ type: "rich_text_list", style, indent, ...(offset ? { offset } : {}), elements: items.map((elements) => ({ type: "rich_text_section", elements })) })),
|
||||
});
|
||||
const t = (text: string, style?: Record<string, boolean>) => (style ? { type: "text", text, style } : { type: "text", text });
|
||||
|
||||
describe("Markdown in a row body renders as Slack mrkdwn", () => {
|
||||
it("turns **bold** and __bold__ into *bold*", () => {
|
||||
expect(md("a **strong** word and __another__ one")).toBe("a *strong* word and *another* one");
|
||||
});
|
||||
|
||||
it("turns # and ## headings into a bold line", () => {
|
||||
expect(md("# Title\ntext\n## Next step ##")).toBe("*Title*\ntext\n*Next step*");
|
||||
expect(md("## **Plan**\n# __Done__")).toBe("*Plan*\n*Done*");
|
||||
});
|
||||
|
||||
it("turns a safe https [text](url) into <url|text>", () => {
|
||||
expect(md("see [the PR](https://example.com/pr/1?a=1&b=2) now")).toBe("see <https://example.com/pr/1?a=1&b=2|the PR> now");
|
||||
});
|
||||
|
||||
it("leaves a non-https or unsafe link as escaped text", () => {
|
||||
expect(md("[x](http://example.com)")).toBe("[x](http://example.com)");
|
||||
expect(md("[x](https://user:[email protected])")).toBe("[x](https://user:[email protected])");
|
||||
expect(md("[a|b](https://example.com)")).toBe("[a|b](https://example.com)");
|
||||
});
|
||||
|
||||
it("writes -, * and + list items as \"- \", keeping indentation", () => {
|
||||
expect(md("- one\n* two\n+ three\n - nested\n * deeper")).toBe("- one\n- two\n- three\n - nested\n - deeper");
|
||||
});
|
||||
|
||||
it("does not read a *bold* line as a list item", () => {
|
||||
expect(md("*bold* start\n* item")).toBe("*bold* start\n- item");
|
||||
});
|
||||
|
||||
it("keeps numbered lists as text", () => {
|
||||
expect(md("1. first\n2. second")).toBe("1. first\n2. second");
|
||||
});
|
||||
|
||||
it("leaves code spans and fenced blocks untouched apart from escaping", () => {
|
||||
expect(md("run `**x** [a](https://e.com)` here")).toBe("run `**x** [a](https://e.com)` here");
|
||||
expect(md("```\n# not a heading\n- not a list\n**x** <y>\n```\n- a list")).toBe(
|
||||
"```\n# not a heading\n- not a list\n**x** <y>\n```\n- a list",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a code span inside a link label", () => {
|
||||
expect(md("[`npm test`](https://example.com)")).toBe("<https://example.com|`npm test`>");
|
||||
});
|
||||
|
||||
it("still neutralizes Slack control syntax and redacts secrets", () => {
|
||||
expect(md("**hi** <!channel> <@U123> & co")).toBe("*hi* <!channel> <@U123> & co");
|
||||
expect(md("[a](https://example.com/<!here>)")).toBe("[a](https://example.com/<!here>)");
|
||||
const out = md("- token xoxb-EXAMPLE-000000000000-doNotUseFake\n[hook](https://hooks.slack.com/services/T00/B00/xyz)");
|
||||
expect(containsSecret(out)).toBe(false);
|
||||
expect(out).toContain("- token [redacted-secret]");
|
||||
expect(out).not.toContain("<");
|
||||
});
|
||||
|
||||
it("is applied to the body section and the fallback text of a posted message", () => {
|
||||
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body: "## Plan\n- **ship** [it](https://example.com)" }, { sourceLabel: "host" });
|
||||
expect(m.text).toContain("*Plan*\n- *ship* <https://example.com|it>");
|
||||
expect(m.blocks).toContainEqual({ type: "section", text: { type: "mrkdwn", text: "*Plan*" } });
|
||||
expect(m.blocks).toContainEqual(list([{ style: "bullet", indent: 0, items: [[{ type: "text", text: "ship", style: { bold: true } }, { type: "text", text: " " }, { type: "link", url: "https://example.com", text: "it" }]] }]));
|
||||
});
|
||||
|
||||
it("keeps input that looks like an internal marker literal, without growing the body", () => {
|
||||
expect(md("before \uE0000\uE001 after <0> <1>")).toBe("before \uE0000\uE001 after <0> <1>");
|
||||
expect(md("`hello` <0>")).toBe("`hello` <0>");
|
||||
const long = "`" + "x".repeat(1600) + "` <0> \uE0000\uE001";
|
||||
expect(md(long).length).toBeLessThanOrEqual(escapeSlackText(long).length);
|
||||
expect(() => buildOutboundMessage({ qitemId: "q", summary: "s", body: long }, { sourceLabel: "host" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("checks a link label for | after restoring its code", () => {
|
||||
expect(md("[`a|b`](https://example.com)")).toBe("[`a|b`](https://example.com)");
|
||||
});
|
||||
|
||||
it("closes a fence only with the same character and at least the opening length", () => {
|
||||
expect(md("````\n```\n# literal\n```\n````\n# heading")).toBe("````\n```\n# literal\n```\n````\n*heading*");
|
||||
expect(md("~~~\n# literal\n**literal**\n```\n~~~\n**bold**")).toBe("~~~\n# literal\n**literal**\n```\n~~~\n*bold*");
|
||||
});
|
||||
|
||||
it("continues an open fence into the next part of a split brief", () => {
|
||||
const body = "```\n" + "x".repeat(1900) + "\n# literal\n**literal**\n```\n# after";
|
||||
const pieces = splitForSlack(body, () => 1910);
|
||||
expect(pieces.length).toBe(2);
|
||||
expect(md(pieces[0]!).endsWith("\n```")).toBe(true);
|
||||
const second = md(pieces[1]!, openFenceAfter(pieces[0]!));
|
||||
expect(second.startsWith("```\n# literal\n**literal**\n```")).toBe(true);
|
||||
expect(second.endsWith("*after*")).toBe(true);
|
||||
expect(md("~~~\nx", "~~~")).toBe("~~~\nx");
|
||||
});
|
||||
|
||||
it("delivers a backtick code block split across Slack messages as a complete ``` block in every part", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "slack-mrkdwn-"));
|
||||
try {
|
||||
const posts: Array<{ blocks: Array<{ text?: { text: string } }> }> = [];
|
||||
const deliver = subsystemSlackDeliver({
|
||||
delivered: new SeenStore(join(home, "delivered")), attempted: new SeenStore(join(home, "attempted")), outboundSeen: new SeenStore(join(home, "seen")),
|
||||
botToken: "synthetic", channel: "C", sourceLabel: "fixture",
|
||||
fetchImpl: async (_url, init) => {
|
||||
posts.push(JSON.parse(String(init?.body)));
|
||||
return new Response(JSON.stringify({ ok: true, ts: `${posts.length}.1` }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
const body = "```\n" + "x ".repeat(2000) + "\n# literal\n- literal\n**literal**\n```\n## Next\n- item";
|
||||
const decision: OutboundDecision = { kind: "outbound_decision", decisionId: "d", op: "post_message", entityBindingRef: "h@external",
|
||||
payload: { qitemId: "q-d", summary: "s", body, sourceSession: "a@rig", destinationSession: "h@external" } };
|
||||
expect(await deliver(decision)).toEqual({ ok: true });
|
||||
expect(posts.length).toBeGreaterThan(1);
|
||||
const last = posts.at(-1)!.blocks;
|
||||
for (const p of posts) {
|
||||
const code = p.blocks.map((b) => b.text?.text ?? "").join("\n").split("```").length - 1;
|
||||
expect(code % 2).toBe(0);
|
||||
}
|
||||
expect(last.map((b) => b.text?.text ?? "").join("\n")).toContain("# literal\n- literal\n**literal**\n```\n*Next*");
|
||||
expect(last.find((b) => b.text?.text.includes("# literal"))!.text!.text.startsWith("```\n")).toBe(true);
|
||||
expect(last).toContainEqual(list([{ style: "bullet", indent: 0, items: [[{ type: "text", text: "item" }]] }]));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a Slack-format link written by hand live, under the same checks", () => {
|
||||
expect(md("- <https://github.com/o/r/pull/1|PR #1> and <https://example.com/a?b=1&c=2>")).toBe(
|
||||
"- <https://github.com/o/r/pull/1|PR #1> and <https://example.com/a?b=1&c=2>",
|
||||
);
|
||||
expect(md("<http://example.com|x> <https://user:[email protected]|x> <!channel> <@U1> <https://e.com|a|b>")).toBe(
|
||||
"<http://example.com|x> <https://user:[email protected]|x> <!channel> <@U1> <https://e.com|a|b>",
|
||||
);
|
||||
expect(md("`<https://example.com|x>`")).toBe("`<https://example.com|x>`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("list items in a row body render as native Slack lists", () => {
|
||||
it("groups items into rich_text lists, nested by indent, bullet or ordered", () => {
|
||||
expect(buildBodyBlocks("Intro\n- one\n* two\n + nested\n- three\n1. first\n2) second\nOutro")).toEqual([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "Intro" } },
|
||||
list([
|
||||
{ style: "bullet", indent: 0, items: [[t("one")], [t("two")]] },
|
||||
{ style: "bullet", indent: 1, items: [[t("nested")]] },
|
||||
{ style: "bullet", indent: 0, items: [[t("three")]] },
|
||||
{ style: "ordered", indent: 0, items: [[t("first")], [t("second")]] },
|
||||
]),
|
||||
{ type: "section", text: { type: "mrkdwn", text: "Outro" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("styles bold and code, and links only safe https URLs", () => {
|
||||
const [block] = buildBodyBlocks("- **a** __b__ *c* `**d**` [e](https://e.com) <https://f.com|f> <https://g.com> [h](http://h.com) <https://u:[email protected]|i>") as Array<{ elements: Array<{ elements: Array<{ elements: unknown[] }> }> }>;
|
||||
expect(block!.elements[0]!.elements[0]!.elements).toEqual([
|
||||
t("a", { bold: true }), t(" "), t("b", { bold: true }), t(" "), t("c", { bold: true }), t(" "), t("**d**", { code: true }), t(" "),
|
||||
{ type: "link", url: "https://e.com", text: "e" }, t(" "), { type: "link", url: "https://f.com", text: "f" }, t(" "),
|
||||
{ type: "link", url: "https://g.com" }, t(" [h](http://h.com) <https://u:[email protected]|i>"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mentions as literal text, redacts secrets, and never emits empty text", () => {
|
||||
const json = JSON.stringify(buildBodyBlocks("- <!channel> <@U1> xoxb-EXAMPLE-000000000000-doNotUseFake\n- "));
|
||||
expect(json).toContain('"text":"<!channel> <@U1> [redacted-secret]"');
|
||||
expect(containsSecret(json)).toBe(false);
|
||||
expect(json).not.toContain('"text":""');
|
||||
});
|
||||
|
||||
it("leaves list lines inside a code fence in the mrkdwn section", () => {
|
||||
expect(buildBodyBlocks("```\n- not a list\n```\n- a list")).toEqual([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "```\n- not a list\n```" } },
|
||||
list([{ style: "bullet", indent: 0, items: [[t("a list")]] }]),
|
||||
]);
|
||||
expect(buildBodyBlocks("- still code\n```", "```")[0]).toEqual({ type: "section", text: { type: "mrkdwn", text: "```\n- still code\n```" } });
|
||||
});
|
||||
|
||||
it("falls back to one mrkdwn section when list runs would pass 50 blocks", () => {
|
||||
const body = Array.from({ length: 30 }, (_, i) => `text ${i}\n- item ${i}`).join("\n");
|
||||
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body }, { sourceLabel: "host" });
|
||||
expect(m.blocks.length).toBeLessThanOrEqual(50);
|
||||
expect(m.blocks.some((b) => (b as { type: string }).type === "rich_text")).toBe(false);
|
||||
expect(m.text).toContain("text 29\n- item 29");
|
||||
});
|
||||
|
||||
it("never emits an empty section around a list", () => {
|
||||
for (const body of ["# \n- item", "- item\n# ", "\n# \n- item\n \n"]) {
|
||||
expect(buildBodyBlocks(body)).toEqual([list([{ style: "bullet", indent: 0, items: [[t("item")]] }])]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps ordered numbering when a list starts later or resumes after nesting", () => {
|
||||
expect(buildBodyBlocks("5. five\n6. six")).toEqual([list([{ style: "ordered", indent: 0, offset: 4, items: [[t("five")], [t("six")]] }])]);
|
||||
expect(buildBodyBlocks("1. one\n - sub\n2. two")).toEqual([list([
|
||||
{ style: "ordered", indent: 0, items: [[t("one")]] },
|
||||
{ style: "bullet", indent: 1, items: [[t("sub")]] },
|
||||
{ style: "ordered", indent: 0, offset: 1, items: [[t("two")]] },
|
||||
])]);
|
||||
});
|
||||
|
||||
it("adds no empty code section before a list when a part continues a code block", () => {
|
||||
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body: "code\n```\n- one\n- two", bodyOpenFence: "```" }, { sourceLabel: "host" });
|
||||
const body = m.blocks.slice(2);
|
||||
expect(body).toEqual([
|
||||
{ type: "section", text: { type: "mrkdwn", text: "```\ncode\n```" } },
|
||||
list([{ style: "bullet", indent: 0, items: [[t("one")], [t("two")]] }]),
|
||||
]);
|
||||
});
|
||||
|
||||
it("links a bare https URL in a list item, and keeps it byte for byte", () => {
|
||||
const url = "https://docs.python.org/3/reference/datamodel.html#object.__init__";
|
||||
expect(buildBodyBlocks(`- PR: https://github.com/o/r/pull/1\n- see ${url}.\n- https://u:[email protected]`)).toEqual([list([{ style: "bullet", indent: 0, items: [
|
||||
[t("PR: "), { type: "link", url: "https://github.com/o/r/pull/1" }],
|
||||
[t("see "), { type: "link", url }, t(".")],
|
||||
[t("https://u:[email protected]")],
|
||||
] }])]);
|
||||
});
|
||||
|
||||
it("keeps a bare URL out of bold conversion in mrkdwn text", () => {
|
||||
const url = "https://docs.python.org/3/reference/datamodel.html#object.__init__";
|
||||
expect(md(`see ${url} and **b**`)).toBe(`see ${url} and *b*`);
|
||||
expect(md("https://example.com/a?x=1&y=__z__")).toBe("https://example.com/a?x=1&y=__z__");
|
||||
});
|
||||
|
||||
it("keeps balanced parentheses in a bare URL and leaves sentence brackets outside", () => {
|
||||
const wiki = "https://en.wikipedia.org/wiki/Function_(mathematics)";
|
||||
expect(buildBodyBlocks(`- ${wiki}\n- (see ${wiki}).`)).toEqual([list([{ style: "bullet", indent: 0, items: [
|
||||
[{ type: "link", url: wiki }],
|
||||
[t("(see "), { type: "link", url: wiki }, t(").")],
|
||||
] }])]);
|
||||
expect(md(`(see ${wiki}).`)).toBe(`(see ${wiki}).`);
|
||||
});
|
||||
|
||||
it("keeps bold that encloses a bare URL", () => {
|
||||
expect(md("**See https://example.com** and __See https://example.com__")).toBe("*See https://example.com* and *See https://example.com*");
|
||||
expect(buildBodyBlocks("- **See https://example.com** now")).toEqual([list([{ style: "bullet", indent: 0, items: [
|
||||
[t("See ", { bold: true }), { type: "link", url: "https://example.com" }, t(" now")],
|
||||
] }])]);
|
||||
});
|
||||
|
||||
it("keeps balanced parentheses in a Markdown link target", () => {
|
||||
const wiki = "https://en.wikipedia.org/wiki/Function_(mathematics)";
|
||||
expect(md(`see [wiki](${wiki}).`)).toBe(`see <${wiki}|wiki>.`);
|
||||
expect(buildBodyBlocks(`- [wiki](${wiki})`)).toEqual([list([{ style: "bullet", indent: 0, items: [[{ type: "link", url: wiki, text: "wiki" }]] }])]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user