diff --git a/packages/daemon/src/domain/gateway/slack/message.ts b/packages/daemon/src/domain/gateway/slack/message.ts index fd023cf5d..f6202c1d7 100644 --- a/packages/daemon/src/domain/gateway/slack/message.ts +++ b/packages/daemon/src/domain/gateway/slack/message.ts @@ -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 `` can mark a code span or link set aside + * without colliding with input. A Markdown `[text](url)` or a Slack `` / `` 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 `` 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 }; diff --git a/packages/daemon/src/domain/gateway/slack/slack-delivery.ts b/packages/daemon/src/domain/gateway/slack/slack-delivery.ts index c92560981..d824714b9 100644 --- a/packages/daemon/src/domain/gateway/slack/slack-delivery.ts +++ b/packages/daemon/src/domain/gateway/slack/slack-delivery.ts @@ -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.`); } diff --git a/packages/daemon/test/slack-markdown-mrkdwn.test.ts b/packages/daemon/test/slack-markdown-mrkdwn.test.ts new file mode 100644 index 000000000..d4fae2403 --- /dev/null +++ b/packages/daemon/test/slack-markdown-mrkdwn.test.ts @@ -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>; +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) => (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 ", () => { + expect(md("see [the PR](https://example.com/pr/1?a=1&b=2) now")).toBe("see 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:pw@example.com)")).toBe("[x](https://user:pw@example.com)"); + 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** \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(""); + }); + + it("still neutralizes Slack control syntax and redacts secrets", () => { + expect(md("**hi** <@U123> & co")).toBe("*hi* <!channel> <@U123> & co"); + expect(md("[a](https://example.com/)")).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* "); + 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("- and ")).toBe( + "- and ", + ); + expect(md(" <@U1> ")).toBe( + "<http://example.com|x> <https://user:pw@example.com|x> <!channel> <@U1> <https://e.com|a|b>", + ); + expect(md("``")).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) [h](http://h.com) ") 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) "), + ]); + }); + + it("keeps mentions as literal text, redacts secrets, and never emits empty text", () => { + const json = JSON.stringify(buildBodyBlocks("- <@U1> xoxb-EXAMPLE-000000000000-doNotUseFake\n- ")); + expect(json).toContain('"text":" <@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:p@example.com`)).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:p@example.com")], + ] }])]); + }); + + 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" }]] }])]); + }); +});