From c508ffadcc1dffe8fd846920689ba631abfca76a Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 15:29:17 -0700 Subject: [PATCH 1/6] fix(message): render Markdown links in Slack --- README.md | 2 +- skills/agent-slack/SKILL.md | 2 +- src/slack/format-outbound.ts | 36 ++++++++++++++++++++++++++++--- src/slack/rich-text.ts | 13 ++++++++++-- test/format-outbound.test.ts | 22 +++++++++++++++++++ test/message-send.test.ts | 41 ++++++++++++++++++++++++++++++++++++ test/rich-text.test.ts | 12 +++++++++-- 7 files changed, 119 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7fb9c07..091d690 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ agent-slack message edit "#general" "Updated text" --workspace "myteam" --ts "17 agent-slack message delete "#general" --workspace "myteam" --ts "1770165109.628379" ``` -`message edit` and ordinary `message send` calls convert bullet/numbered lists to Slack native rich text. `message send --blocks` uses the supplied blocks instead, while `message send --attach` sends its initial comment as plain text without automatic list conversion. Inside auto-converted lists, inline mentions, broadcasts, emoji shortcodes, `<#C...>` channel references, and Slack manual links such as `` are preserved as Slack elements. CommonMark links such as `[PR #42](https://example.com/pull/42)` are not converted into labeled link elements. +`message edit` and ordinary `message send` calls normalize inline Markdown links and convert bullet/numbered lists to Slack native rich text. `message send --blocks` uses the supplied blocks instead, while `message send --attach` sends its initial comment as plain text without automatic list conversion. Links may use Markdown (`[PR #42](https://example.com/pull/42)`) or Slack (``) syntax. Inside auto-converted lists, links, inline mentions, broadcasts, emoji shortcodes, and `<#C...>` channel references become Slack elements. Send options for `message send`: diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index c1143e1..20227d2 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -37,7 +37,7 @@ Named `later remind --in` values such as `tomorrow` or `monday` also use the exe Use `--no-unfurl` with `message send` or `message compose` when the user wants Slack link and media previews suppressed. It cannot be combined with `message send --attach`. -Ordinary `message send` and `message edit` calls auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Inside auto-converted lists, use Slack's `` syntax because CommonMark `[label](URL)` links are not converted into labeled link elements. +Ordinary `message send` and `message edit` calls normalize `[label](URL)` and `` links and auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. `create` and `update` accept repeatable `--attach `; on `update` the files are added to the draft's existing attachments rather than replacing them. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd). diff --git a/src/slack/format-outbound.ts b/src/slack/format-outbound.ts index e957884..f792469 100644 --- a/src/slack/format-outbound.ts +++ b/src/slack/format-outbound.ts @@ -8,17 +8,47 @@ * `` / `` / `` * * Humans (and LLMs piping text into the CLI) commonly write `@U123` and - * raw `&`/`<`/`>` — this helper normalizes that to what Slack expects, - * while leaving already-well-formed Slack tokens intact. + * `[label](https://example.com)` links as well as raw `&`/`<`/`>` — this + * helper normalizes those to what Slack expects, while leaving + * already-well-formed Slack tokens intact. */ export function formatOutboundSlackText(text: string): string { if (!text) { return ""; } + const codeStash: string[] = []; + let out = text.replace(/(`+)[\s\S]*?\1/g, (match) => { + codeStash.push(match); + return `\uE000${codeStash.length - 1}\uE001`; + }); + + // Slack does not understand CommonMark links in message text. Normalize the + // common inline form while leaving images, escaped links, and code alone. + out = out.replace( + /(? { + const rawLabel = String(match[1]); + const rawUrl = String(match[2]); + const label = rawLabel + .replace(/\\([\\[\]()])/g, "$1") + .replace(/&/g, "&") + .replace(//g, ">"); + const url = rawUrl + .replace(/\\([\\[\]()])/g, "$1") + .replace(/\|/g, "%7C") + .replace(//g, "%3E"); + return `<${url}|${label}>`; + }, + ); + + out = out.replace(/\uE000(\d+)\uE001/g, (_match, idx) => codeStash[Number(idx)]!); + // Protect already-formatted Slack tokens so `<`/`>` inside them aren't escaped. const stash: string[] = []; - let out = text.replace( + out = out.replace( /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:https?:\/\/|mailto:)[^>]+)>/g, (m) => { stash.push(m); diff --git a/src/slack/rich-text.ts b/src/slack/rich-text.ts index 2572666..76ef68f 100644 --- a/src/slack/rich-text.ts +++ b/src/slack/rich-text.ts @@ -42,12 +42,13 @@ const BLOCKQUOTE_RE = /^> (.*)$/; /** * Parse mrkdwn inline formatting into Slack rich_text inline elements. * - * Handles: *bold*, _italic_, ~strike~, `code`, :emoji:, , + * Handles: *bold*, _italic_, ~strike~, `code`, :emoji:, , , + * and [label](url). */ export function parseInlineElements(text: string): InlineElement[] { const elements: InlineElement[] = []; const re = - /`([^`]+)`|(?:^|(?<=[^A-Za-z0-9_])):([a-zA-Z0-9_+-]+):(?![A-Za-z0-9_+-])|\*([^*]+)\*|_([^_]+)_|~([^~]+)~|<@([UWB][A-Z0-9]+)(?:\|[^>]*)?>|<#([CG][A-Z0-9]+)(?:\|[^>]*)?>|]*)?>|]*)?>|<([^>|]+)\|([^>]+)>|<([^>|]+)>|(?:^|(?<=[^A-Za-z0-9_]))@([UWB][A-Z0-9]{6,})\b|(?:^|(?<=[^A-Za-z0-9_]))@(here|channel|everyone)\b/g; + /`([^`]+)`|(?:^|(?<=[^A-Za-z0-9_])):([a-zA-Z0-9_+-]+):(?![A-Za-z0-9_+-])|\*([^*]+)\*|_([^_]+)_|~([^~]+)~|<@([UWB][A-Z0-9]+)(?:\|[^>]*)?>|<#([CG][A-Z0-9]+)(?:\|[^>]*)?>|]*)?>|]*)?>|(?|]+)\|([^>]+)>|<([^>|]+)>|(?:^|(?<=[^A-Za-z0-9_]))@([UWB][A-Z0-9]{6,})\b|(?:^|(?<=[^A-Za-z0-9_]))@(here|channel|everyone)\b/g; let lastIndex = 0; let match: RegExpExecArray | null; @@ -73,6 +74,8 @@ export function parseInlineElements(text: string): InlineElement[] { channelToken, usergroupToken, broadcastToken, + markdownLinkText, + markdownLinkUrl, linkUrl, linkText, bareUrl, @@ -100,6 +103,12 @@ export function parseInlineElements(text: string): InlineElement[] { type: "broadcast", range: broadcastToken as "here" | "channel" | "everyone", }); + } else if (markdownLinkText != null && markdownLinkUrl != null) { + elements.push({ + type: "link", + url: markdownLinkUrl.replace(/\\([\\[\]()])/g, "$1"), + text: markdownLinkText.replace(/\\([\\[\]()])/g, "$1"), + }); } else if (linkUrl != null && linkText != null && isSlackManualLinkUrl(linkUrl)) { elements.push({ type: "link", url: linkUrl, text: linkText }); } else if (linkUrl != null && linkText != null) { diff --git a/test/format-outbound.test.ts b/test/format-outbound.test.ts index 59eb100..10024c2 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -44,6 +44,28 @@ describe("formatOutboundSlackText", () => { ); }); + test("converts inline Markdown links to Slack links", () => { + expect( + formatOutboundSlackText( + "[MX-55362](https://meraki.atlassian.net/browse/MX-55362) | [PR #23229](https://github.com/net-plat-eng/dashboard-server/pull/23229)", + ), + ).toBe( + " | ", + ); + }); + + test("preserves Markdown-like text in code, images, and escaped links", () => { + const input = + "`[code](https://example.com/code)` ![image](https://example.com/image.png) \\[escaped](https://example.com/escaped)"; + expect(formatOutboundSlackText(input)).toBe(input); + }); + + test("handles parenthesized link destinations and safely escapes labels", () => { + expect(formatOutboundSlackText("[A & ](https://example.com/wiki/Foo_(bar)?x=1&y=2)")).toBe( + "", + ); + }); + test("does not promote email-like or mid-word @", () => { expect(formatOutboundSlackText("mail me at user@Udomain.com")).toBe( "mail me at user@Udomain.com", diff --git a/test/message-send.test.ts b/test/message-send.test.ts index 12ff5e3..236d856 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -600,6 +600,47 @@ describe("sendMessage", () => { ]); }); + test("converts Markdown links in ordinary message text", async () => { + const calls: { method: string; params: Record }[] = []; + const ctx = createContext(calls); + + await sendMessage({ + ctx, + targetInput: "C12345678", + text: "Review [PR #42](https://example.com/pull/42)", + options: {}, + }); + + expect(calls[0]?.method).toBe("chat.postMessage"); + expect(calls[0]?.params.text).toBe("Review "); + expect(calls[0]?.params.blocks).toBeUndefined(); + }); + + test("converts Markdown links in lists to rich-text link blocks", async () => { + const calls: { method: string; params: Record }[] = []; + const ctx = createContext(calls); + + await sendMessage({ + ctx, + targetInput: "C12345678", + text: "- Review [PR #42](https://example.com/pull/42)", + options: {}, + }); + + const blocks = calls[0]!.params.blocks as { elements: { elements?: unknown[] }[] }[]; + const [block] = blocks; + const [list] = block!.elements; + expect(list?.elements).toEqual([ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "Review " }, + { type: "link", url: "https://example.com/pull/42", text: "PR #42" }, + ], + }, + ]); + }); + test("--blocks: errors when an array element is not an object", async () => { const calls: { method: string; params: Record }[] = []; const ctx = createContext(calls); diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index e619bc6..1033626 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -74,6 +74,13 @@ describe("parseInlineElements", () => { ]); }); + test("Markdown links are parsed as links with text", () => { + expect(parseInlineElements("Review [PR #42](https://example.com/pull/42)")).toEqual([ + { type: "text", text: "Review " }, + { type: "link", url: "https://example.com/pull/42", text: "PR #42" }, + ]); + }); + test("non-url angle bracket text is preserved as text", () => { expect(parseInlineElements("Use ")).toEqual([ { type: "text", text: "Use " }, @@ -269,7 +276,7 @@ describe("textToRichTextBlocks", () => { ]); }); - test("Slack manual links and CommonMark links remain distinct in list items", () => { + test("Slack manual and Markdown links become link elements in list items", () => { const result = textToRichTextBlocks( "- Review \n- Review [PR #43](https://example.com/pull/43)", )!; @@ -281,7 +288,8 @@ describe("textToRichTextBlocks", () => { { type: "link", url: "https://example.com/pull/42", text: "PR #42" }, ]); expect(list.elements[1]!.elements).toEqual([ - { type: "text", text: "Review [PR #43](https://example.com/pull/43)" }, + { type: "text", text: "Review " }, + { type: "link", url: "https://example.com/pull/43", text: "PR #43" }, ]); }); From 3d7a57c724bf3de4ce66098e28a7cb8def096d43 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 15:44:58 -0700 Subject: [PATCH 2/6] fix(message): handle Markdown link edge cases --- src/slack/format-outbound.ts | 31 +---- src/slack/markdown-inline.ts | 219 +++++++++++++++++++++++++++++++++++ src/slack/rich-text.ts | 126 +++++++++++++++++--- test/drafts.test.ts | 39 +++++++ test/format-outbound.test.ts | 15 +++ test/rich-text.test.ts | 40 +++++++ 6 files changed, 424 insertions(+), 46 deletions(-) create mode 100644 src/slack/markdown-inline.ts diff --git a/src/slack/format-outbound.ts b/src/slack/format-outbound.ts index f792469..a320221 100644 --- a/src/slack/format-outbound.ts +++ b/src/slack/format-outbound.ts @@ -1,3 +1,5 @@ +import { markdownLinksToSlackMrkdwn } from "./markdown-inline.ts"; + /** * Prepare user-authored text for Slack's `chat.postMessage` / `chat.update`. * @@ -17,39 +19,14 @@ export function formatOutboundSlackText(text: string): string { return ""; } - const codeStash: string[] = []; - let out = text.replace(/(`+)[\s\S]*?\1/g, (match) => { - codeStash.push(match); - return `\uE000${codeStash.length - 1}\uE001`; - }); - // Slack does not understand CommonMark links in message text. Normalize the // common inline form while leaving images, escaped links, and code alone. - out = out.replace( - /(? { - const rawLabel = String(match[1]); - const rawUrl = String(match[2]); - const label = rawLabel - .replace(/\\([\\[\]()])/g, "$1") - .replace(/&/g, "&") - .replace(//g, ">"); - const url = rawUrl - .replace(/\\([\\[\]()])/g, "$1") - .replace(/\|/g, "%7C") - .replace(//g, "%3E"); - return `<${url}|${label}>`; - }, - ); - - out = out.replace(/\uE000(\d+)\uE001/g, (_match, idx) => codeStash[Number(idx)]!); + let out = markdownLinksToSlackMrkdwn(text); // Protect already-formatted Slack tokens so `<`/`>` inside them aren't escaped. const stash: string[] = []; out = out.replace( - /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:https?:\/\/|mailto:)[^>]+)>/g, + /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:https?:\/\/|mailto:)[^>]+)>/gi, (m) => { stash.push(m); return `\u0000${stash.length - 1}\u0000`; diff --git a/src/slack/markdown-inline.ts b/src/slack/markdown-inline.ts new file mode 100644 index 0000000..bdfe94a --- /dev/null +++ b/src/slack/markdown-inline.ts @@ -0,0 +1,219 @@ +type ParsedCodeSpan = { + content: string; + end: number; +}; + +type ParsedMarkdownLink = { + label: string; + url: string; + end: number; +}; + +export function parseCodeSpanAt(text: string, start: number): ParsedCodeSpan | null { + if (text[start] !== "`" || isEscaped(text, start)) { + return null; + } + + const openerLength = countBacktickRun(text, start); + const contentStart = start + openerLength; + let cursor = contentStart; + + while (cursor < text.length) { + if (text[cursor] !== "`") { + cursor++; + continue; + } + + const closerLength = countBacktickRun(text, cursor); + if (closerLength === openerLength) { + return { + content: text.slice(contentStart, cursor), + end: cursor + closerLength, + }; + } + cursor += closerLength; + } + + return null; +} + +export function parseMarkdownLinkAt(text: string, start: number): ParsedMarkdownLink | null { + if ( + text[start] !== "[" || + isEscaped(text, start) || + (text[start - 1] === "!" && !isEscaped(text, start - 1)) + ) { + return null; + } + + const labelEnd = findBalancedEnd({ + text, + start: start + 1, + open: "[", + close: "]", + rejectWhitespace: false, + }); + if (labelEnd == null || text[labelEnd + 1] !== "(") { + return null; + } + + const destinationStart = labelEnd + 2; + const destination = parseLinkDestination(text, destinationStart); + if (!destination) { + return null; + } + + const rawUrl = unescapeMarkdownPunctuation(destination.value); + const scheme = /^(https?|mailto):/i.exec(rawUrl); + if (!scheme) { + return null; + } + + return { + label: unescapeMarkdownPunctuation(text.slice(start + 1, labelEnd)), + url: `${scheme[1]!.toLowerCase()}:${rawUrl.slice(scheme[0].length)}`, + end: destination.end, + }; +} + +export function markdownLinksToSlackMrkdwn(text: string): string { + let output = ""; + let cursor = 0; + + while (cursor < text.length) { + const codeSpan = parseCodeSpanAt(text, cursor); + if (codeSpan) { + output += text.slice(cursor, codeSpan.end); + cursor = codeSpan.end; + continue; + } + + const link = parseMarkdownLinkAt(text, cursor); + if (link) { + const label = link.label.replace(/&/g, "&").replace(//g, ">"); + const url = link.url.replace(/\|/g, "%7C").replace(//g, "%3E"); + output += `<${url}|${label}>`; + cursor = link.end; + continue; + } + + output += text[cursor]; + cursor++; + } + + return output; +} + +function parseLinkDestination(text: string, start: number): { value: string; end: number } | null { + if (text[start] === "<") { + let cursor = start + 1; + while (cursor < text.length) { + const char = text[cursor]!; + if (char === "\\" && cursor + 1 < text.length) { + cursor += 2; + continue; + } + if (char === ">") { + if (text[cursor + 1] !== ")") { + return null; + } + return { value: text.slice(start + 1, cursor), end: cursor + 2 }; + } + if (char === "<" || /\s/.test(char)) { + return null; + } + cursor++; + } + return null; + } + + const end = findBalancedEnd({ + text, + start, + open: "(", + close: ")", + rejectWhitespace: true, + }); + if (end == null || end === start) { + return null; + } + return { value: text.slice(start, end), end: end + 1 }; +} + +function findBalancedEnd(input: { + text: string; + start: number; + open: string; + close: string; + rejectWhitespace: boolean; +}): number | null { + const { text, start, open, close, rejectWhitespace } = input; + let depth = 1; + let cursor = start; + + while (cursor < text.length) { + const char = text[cursor]!; + if (char === "\\" && cursor + 1 < text.length) { + cursor += 2; + continue; + } + if (char === "\n" || (rejectWhitespace && /\s/.test(char))) { + return null; + } + if (char === open) { + depth++; + } else if (char === close) { + depth--; + if (depth === 0) { + return cursor; + } + } + cursor++; + } + + return null; +} + +function countBacktickRun(text: string, start: number): number { + let cursor = start; + while (text[cursor] === "`") { + cursor++; + } + return cursor - start; +} + +function isEscaped(text: string, index: number): boolean { + let slashCount = 0; + for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { + slashCount++; + } + return slashCount % 2 === 1; +} + +function unescapeMarkdownPunctuation(value: string): string { + let output = ""; + let cursor = 0; + + while (cursor < value.length) { + const next = value[cursor + 1]; + if (value[cursor] === "\\" && next && isAsciiPunctuation(next)) { + output += next; + cursor += 2; + continue; + } + output += value[cursor]; + cursor++; + } + + return output; +} + +function isAsciiPunctuation(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 0x21 && code <= 0x2f) || + (code >= 0x3a && code <= 0x40) || + (code >= 0x5b && code <= 0x60) || + (code >= 0x7b && code <= 0x7e) + ); +} diff --git a/src/slack/rich-text.ts b/src/slack/rich-text.ts index 76ef68f..385516a 100644 --- a/src/slack/rich-text.ts +++ b/src/slack/rich-text.ts @@ -1,3 +1,5 @@ +import { parseCodeSpanAt, parseMarkdownLinkAt } from "./markdown-inline.ts"; + type InlineStyle = { bold?: true; italic?: true; strike?: true; code?: true }; type InlineElement = @@ -46,9 +48,79 @@ const BLOCKQUOTE_RE = /^> (.*)$/; * and [label](url). */ export function parseInlineElements(text: string): InlineElement[] { + const protectedInline = protectInlineCodeAndLinks(text); + return parseProtectedInlineElements(protectedInline.text, protectedInline); +} + +type ProtectedInlineToken = + | { type: "code"; content: string } + | { type: "link"; label: string; url: string }; + +type ProtectedInlineContext = { + text: string; + tokens: ProtectedInlineToken[]; + marker: string; + suffix: string; +}; + +function protectInlineCodeAndLinks(text: string): ProtectedInlineContext { + let marker = "\uE000"; + while (text.includes(marker)) { + marker += "\uE000"; + } + const suffix = "\uE001"; + const tokens: ProtectedInlineToken[] = []; + let protectedText = ""; + let cursor = 0; + + while (cursor < text.length) { + const codeSpan = parseCodeSpanAt(text, cursor); + if (codeSpan) { + tokens.push({ type: "code", content: codeSpan.content }); + protectedText += `${marker}${tokens.length - 1}${suffix}`; + cursor = codeSpan.end; + continue; + } + + const link = parseMarkdownLinkAt(text, cursor); + if (link) { + tokens.push({ type: "link", label: link.label, url: link.url }); + protectedText += `${marker}${tokens.length - 1}${suffix}`; + cursor = link.end; + continue; + } + + protectedText += text[cursor]; + cursor++; + } + + return { text: protectedText, tokens, marker, suffix }; +} + +function parseProtectedInlineElements( + text: string, + context: ProtectedInlineContext, +): InlineElement[] { + const { tokens, marker, suffix } = context; const elements: InlineElement[] = []; - const re = - /`([^`]+)`|(?:^|(?<=[^A-Za-z0-9_])):([a-zA-Z0-9_+-]+):(?![A-Za-z0-9_+-])|\*([^*]+)\*|_([^_]+)_|~([^~]+)~|<@([UWB][A-Z0-9]+)(?:\|[^>]*)?>|<#([CG][A-Z0-9]+)(?:\|[^>]*)?>|]*)?>|]*)?>|(?|]+)\|([^>]+)>|<([^>|]+)>|(?:^|(?<=[^A-Za-z0-9_]))@([UWB][A-Z0-9]{6,})\b|(?:^|(?<=[^A-Za-z0-9_]))@(here|channel|everyone)\b/g; + const re = new RegExp( + [ + `${escapeRegExp(marker)}(?\\d+)${escapeRegExp(suffix)}`, + "(?:^|(?<=[^A-Za-z0-9_])):(?[a-zA-Z0-9_+-]+):(?![A-Za-z0-9_+-])", + "\\*(?[^*]+)\\*", + "_(?[^_]+)_", + "~(?[^~]+)~", + "<@(?[UWB][A-Z0-9]+)(?:\\|[^>]*)?>", + "<#(?[CG][A-Z0-9]+)(?:\\|[^>]*)?>", + "[A-Z0-9]+)(?:\\|[^>]*)?>", + "here|channel|everyone)(?:\\|[^>]*)?>", + "<(?[^>|]+)\\|(?[^>]+)>", + "<(?[^>|]+)>", + "(?:^|(?<=[^A-Za-z0-9_]))@(?[UWB][A-Z0-9]{6,})\\b", + "(?:^|(?<=[^A-Za-z0-9_]))@(?here|channel|everyone)\\b", + ].join("|"), + "g", + ); let lastIndex = 0; let match: RegExpExecArray | null; @@ -63,9 +135,9 @@ export function parseInlineElements(text: string): InlineElement[] { pushText(text.slice(lastIndex, match.index)); } - const [ - , - code, + const groups = match.groups ?? {}; + const { + protectedTokenIndex, emojiName, bold, italic, @@ -74,24 +146,33 @@ export function parseInlineElements(text: string): InlineElement[] { channelToken, usergroupToken, broadcastToken, - markdownLinkText, - markdownLinkUrl, linkUrl, linkText, bareUrl, bareUserId, bareBroadcast, - ] = match; - if (code != null) { - elements.push({ type: "text", text: code, style: { code: true } }); + } = groups; + if (protectedTokenIndex != null) { + const token = tokens[Number(protectedTokenIndex)]!; + if (token.type === "code") { + elements.push({ type: "text", text: token.content, style: { code: true } }); + } else { + elements.push({ type: "link", url: token.url, text: token.label }); + } } else if (emojiName != null) { elements.push({ type: "emoji", name: emojiName }); } else if (bold != null) { - elements.push({ type: "text", text: bold, style: { bold: true } }); + elements.push( + ...applyInlineStyle(parseProtectedInlineElements(bold, context), { bold: true }), + ); } else if (italic != null) { - elements.push({ type: "text", text: italic, style: { italic: true } }); + elements.push( + ...applyInlineStyle(parseProtectedInlineElements(italic, context), { italic: true }), + ); } else if (strike != null) { - elements.push({ type: "text", text: strike, style: { strike: true } }); + elements.push( + ...applyInlineStyle(parseProtectedInlineElements(strike, context), { strike: true }), + ); } else if (userToken != null) { elements.push({ type: "user", user_id: userToken }); } else if (channelToken != null) { @@ -103,12 +184,6 @@ export function parseInlineElements(text: string): InlineElement[] { type: "broadcast", range: broadcastToken as "here" | "channel" | "everyone", }); - } else if (markdownLinkText != null && markdownLinkUrl != null) { - elements.push({ - type: "link", - url: markdownLinkUrl.replace(/\\([\\[\]()])/g, "$1"), - text: markdownLinkText.replace(/\\([\\[\]()])/g, "$1"), - }); } else if (linkUrl != null && linkText != null && isSlackManualLinkUrl(linkUrl)) { elements.push({ type: "link", url: linkUrl, text: linkText }); } else if (linkUrl != null && linkText != null) { @@ -136,6 +211,19 @@ export function parseInlineElements(text: string): InlineElement[] { return elements.length > 0 ? elements : [{ type: "text", text }]; } +function applyInlineStyle(elements: InlineElement[], style: InlineStyle): InlineElement[] { + return elements.map((element) => { + if (element.type !== "text" && element.type !== "link") { + return element; + } + return { ...element, style: { ...element.style, ...style } }; + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function isSlackManualLinkUrl(value: string): boolean { return /^(?:https?:\/\/|mailto:)/i.test(value); } diff --git a/test/drafts.test.ts b/test/drafts.test.ts index 1dfee4d..a488b5c 100644 --- a/test/drafts.test.ts +++ b/test/drafts.test.ts @@ -71,6 +71,45 @@ describe("draftTextToBlocks", () => { expect(blocks).toHaveLength(1); expect(blocks[0]?.elements.some((el) => el.type === "rich_text_list")).toBe(true); }); + + test("keeps emphasis around native Markdown link elements", () => { + expect(draftTextToBlocks("*Review [PR](https://e.test)*")).toEqual([ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "Review ", style: { bold: true } }, + { type: "link", url: "https://e.test", text: "PR", style: { bold: true } }, + { type: "text", text: "\n" }, + ], + }, + ], + }, + ]); + }); + + test("keeps Markdown links inside multi-backtick code spans as code", () => { + expect(draftTextToBlocks("``foo ` [link](https://e.test)``")).toEqual([ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { + type: "text", + text: "foo ` [link](https://e.test)", + style: { code: true }, + }, + { type: "text", text: "\n" }, + ], + }, + ], + }, + ]); + }); }); describe("parseDraftRecord", () => { diff --git a/test/format-outbound.test.ts b/test/format-outbound.test.ts index 10024c2..1b9e5b2 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -66,6 +66,21 @@ describe("formatOutboundSlackText", () => { ); }); + test("handles uppercase schemes and canonicalizes them for Slack", () => { + expect(formatOutboundSlackText("[Example](HTTPS://E.TEST)")).toBe(""); + }); + + test("handles escaped and nested brackets in link labels", () => { + expect(formatOutboundSlackText("[A \\] [nested]](https://e.test)")).toBe( + "", + ); + }); + + test("preserves Markdown links inside multi-backtick code spans", () => { + const input = "``foo ` [link](https://e.test)``"; + expect(formatOutboundSlackText(input)).toBe(input); + }); + test("does not promote email-like or mid-word @", () => { expect(formatOutboundSlackText("mail me at user@Udomain.com")).toBe( "mail me at user@Udomain.com", diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index 1033626..527c412 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -81,6 +81,25 @@ describe("parseInlineElements", () => { ]); }); + test("Markdown links inside emphasis retain the emphasis", () => { + expect(parseInlineElements("*Review [PR](https://e.test)*")).toEqual([ + { type: "text", text: "Review ", style: { bold: true } }, + { type: "link", url: "https://e.test", text: "PR", style: { bold: true } }, + ]); + }); + + test("multi-backtick code spans keep Markdown links as code", () => { + expect(parseInlineElements("``foo ` [link](https://e.test)``")).toEqual([ + { type: "text", text: "foo ` [link](https://e.test)", style: { code: true } }, + ]); + }); + + test("Markdown links support uppercase schemes and nested labels", () => { + expect(parseInlineElements("[A \\] [nested]](HTTPS://E.TEST)")).toEqual([ + { type: "link", url: "https://E.TEST", text: "A ] [nested]" }, + ]); + }); + test("non-url angle bracket text is preserved as text", () => { expect(parseInlineElements("Use ")).toEqual([ { type: "text", text: "Use " }, @@ -293,6 +312,27 @@ describe("textToRichTextBlocks", () => { ]); }); + test("links inside emphasized list items retain the emphasis", () => { + const result = textToRichTextBlocks("- *Review [PR](https://e.test)*")!; + const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { + elements: { elements: unknown[] }[]; + }; + expect(list.elements[0]!.elements).toEqual([ + { type: "text", text: "Review ", style: { bold: true } }, + { type: "link", url: "https://e.test", text: "PR", style: { bold: true } }, + ]); + }); + + test("multi-backtick code spans in list items do not activate links", () => { + const result = textToRichTextBlocks("- ``foo ` [link](https://e.test)``")!; + const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { + elements: { elements: unknown[] }[]; + }; + expect(list.elements[0]!.elements).toEqual([ + { type: "text", text: "foo ` [link](https://e.test)", style: { code: true } }, + ]); + }); + test("code block is preserved", () => { const result = textToRichTextBlocks("- Item\n```\ncode here\n```")!; expect(result).not.toBeNull(); From 878c4fdc667975147e73e684ebefbc564a9c6186 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 16:03:45 -0700 Subject: [PATCH 3/6] fix(message): harden Markdown parsing --- src/slack/markdown-inline.ts | 371 ++++++++++++++++++++++++----------- src/slack/rich-text.ts | 76 +++---- test/drafts.test.ts | 17 ++ test/format-outbound.test.ts | 7 + test/rich-text.test.ts | 16 ++ 5 files changed, 318 insertions(+), 169 deletions(-) diff --git a/src/slack/markdown-inline.ts b/src/slack/markdown-inline.ts index bdfe94a..112274a 100644 --- a/src/slack/markdown-inline.ts +++ b/src/slack/markdown-inline.ts @@ -9,86 +9,118 @@ type ParsedMarkdownLink = { end: number; }; -export function parseCodeSpanAt(text: string, start: number): ParsedCodeSpan | null { - if (text[start] !== "`" || isEscaped(text, start)) { - return null; - } +type CodeDelimiter = { + openerEnd: number; + closerStart: number; + end: number; +}; - const openerLength = countBacktickRun(text, start); - const contentStart = start + openerLength; - let cursor = contentStart; +type DelimiterIndex = { + codeDelimiters: Map; + squareBracketEnds: Map; + parenthesisEnds: Map; + angleBracketEnds: Map; + escaped: Uint8Array; +}; - while (cursor < text.length) { - if (text[cursor] !== "`") { - cursor++; - continue; - } +type ParserContext = { + text: string; + index: DelimiterIndex; +}; - const closerLength = countBacktickRun(text, cursor); - if (closerLength === openerLength) { - return { - content: text.slice(contentStart, cursor), - end: cursor + closerLength, - }; - } - cursor += closerLength; - } +export type MarkdownInlineParser = { + codeSpanAt: (start: number) => ParsedCodeSpan | null; + markdownLinkAt: (start: number) => ParsedMarkdownLink | null; +}; - return null; +export type ProtectedMarkdownInlineToken = + | { type: "code"; content: string; raw: string } + | { type: "link"; label: string; url: string; raw: string }; + +export type ProtectedMarkdownInline = { + text: string; + tokens: ProtectedMarkdownInlineToken[]; + marker: string; + suffix: string; +}; + +export function createMarkdownInlineParser(text: string): MarkdownInlineParser { + const context = { text, index: buildDelimiterIndex(text) }; + return { + codeSpanAt: (start) => parseCodeSpanAt(context, start), + markdownLinkAt: (start) => parseMarkdownLinkAt(context, start), + }; } -export function parseMarkdownLinkAt(text: string, start: number): ParsedMarkdownLink | null { - if ( - text[start] !== "[" || - isEscaped(text, start) || - (text[start - 1] === "!" && !isEscaped(text, start - 1)) - ) { - return null; +export function protectMarkdownInline(text: string): ProtectedMarkdownInline { + const parser = createMarkdownInlineParser(text); + let marker = "\uE000"; + while (text.includes(marker)) { + marker += "\uE000"; } + const suffix = "\uE001"; + const tokens: ProtectedMarkdownInlineToken[] = []; + let protectedText = ""; + let cursor = 0; - const labelEnd = findBalancedEnd({ - text, - start: start + 1, - open: "[", - close: "]", - rejectWhitespace: false, - }); - if (labelEnd == null || text[labelEnd + 1] !== "(") { - return null; - } + while (cursor < text.length) { + const codeSpan = parser.codeSpanAt(cursor); + if (codeSpan) { + tokens.push({ + type: "code", + content: codeSpan.content, + raw: text.slice(cursor, codeSpan.end), + }); + protectedText += `${marker}${tokens.length - 1}${suffix}`; + cursor = codeSpan.end; + continue; + } - const destinationStart = labelEnd + 2; - const destination = parseLinkDestination(text, destinationStart); - if (!destination) { - return null; - } + const link = parser.markdownLinkAt(cursor); + if (link) { + tokens.push({ + type: "link", + label: link.label, + url: link.url, + raw: text.slice(cursor, link.end), + }); + protectedText += `${marker}${tokens.length - 1}${suffix}`; + cursor = link.end; + continue; + } - const rawUrl = unescapeMarkdownPunctuation(destination.value); - const scheme = /^(https?|mailto):/i.exec(rawUrl); - if (!scheme) { - return null; + protectedText += text[cursor]; + cursor++; } - return { - label: unescapeMarkdownPunctuation(text.slice(start + 1, labelEnd)), - url: `${scheme[1]!.toLowerCase()}:${rawUrl.slice(scheme[0].length)}`, - end: destination.end, - }; + return { text: protectedText, tokens, marker, suffix }; +} + +export function restoreProtectedMarkdownLiterals( + text: string, + context: ProtectedMarkdownInline, +): string { + const { marker, suffix, tokens } = context; + const tokenPattern = new RegExp(`${escapeRegExp(marker)}(\\d+)${escapeRegExp(suffix)}`, "g"); + return text.replace(tokenPattern, (match, tokenIndex) => { + return tokens[Number(tokenIndex)]?.raw ?? match; + }); } export function markdownLinksToSlackMrkdwn(text: string): string { + const parser = createMarkdownInlineParser(text); let output = ""; let cursor = 0; while (cursor < text.length) { - const codeSpan = parseCodeSpanAt(text, cursor); + const codeSpan = parser.codeSpanAt(cursor); if (codeSpan) { output += text.slice(cursor, codeSpan.end); cursor = codeSpan.end; continue; } - const link = parseMarkdownLinkAt(text, cursor); + const link = parser.markdownLinkAt(cursor); if (link) { const label = link.label.replace(/&/g, "&").replace(//g, ">"); const url = link.url.replace(/\|/g, "%7C").replace(//g, "%3E"); @@ -104,90 +136,191 @@ export function markdownLinksToSlackMrkdwn(text: string): string { return output; } -function parseLinkDestination(text: string, start: number): { value: string; end: number } | null { +function parseCodeSpanAt(context: ParserContext, start: number): ParsedCodeSpan | null { + const { text, index } = context; + const delimiter = index.codeDelimiters.get(start); + if (!delimiter) { + return null; + } + return { + content: text.slice(delimiter.openerEnd, delimiter.closerStart), + end: delimiter.end, + }; +} + +function parseMarkdownLinkAt(context: ParserContext, start: number): ParsedMarkdownLink | null { + const { text, index } = context; + if ( + text[start] !== "[" || + index.escaped[start] === 1 || + (text[start - 1] === "!" && index.escaped[start - 1] !== 1) + ) { + return null; + } + + const labelEnd = index.squareBracketEnds.get(start); + if (labelEnd == null || text[labelEnd + 1] !== "(") { + return null; + } + + const destination = parseLinkDestination(context, labelEnd + 1); + if (!destination) { + return null; + } + + const rawUrl = unescapeMarkdownPunctuation(destination.value); + const scheme = /^(https?|mailto):/i.exec(rawUrl)!; + return { + label: unescapeMarkdownPunctuation(text.slice(start + 1, labelEnd)), + url: `${scheme[1]!.toLowerCase()}:${rawUrl.slice(scheme[0].length)}`, + end: destination.end, + }; +} + +function parseLinkDestination( + context: ParserContext, + openingParenthesis: number, +): { value: string; end: number } | null { + const { text, index } = context; + const start = openingParenthesis + 1; + let valueStart = start; + let valueEnd: number | undefined; + let end: number; + if (text[start] === "<") { - let cursor = start + 1; - while (cursor < text.length) { - const char = text[cursor]!; - if (char === "\\" && cursor + 1 < text.length) { - cursor += 2; - continue; - } - if (char === ">") { - if (text[cursor + 1] !== ")") { - return null; - } - return { value: text.slice(start + 1, cursor), end: cursor + 2 }; - } - if (char === "<" || /\s/.test(char)) { - return null; - } - cursor++; + valueStart++; + valueEnd = index.angleBracketEnds.get(start); + if (valueEnd == null || text[valueEnd + 1] !== ")") { + return null; } - return null; + end = valueEnd + 2; + } else { + valueEnd = index.parenthesisEnds.get(openingParenthesis); + if (valueEnd == null || valueEnd === start) { + return null; + } + end = valueEnd + 1; } - const end = findBalancedEnd({ - text, - start, - open: "(", - close: ")", - rejectWhitespace: true, - }); - if (end == null || end === start) { + const value = text.slice(valueStart, valueEnd); + if (!isSupportedLinkDestination(value)) { return null; } - return { value: text.slice(start, end), end: end + 1 }; + return { value, end }; } -function findBalancedEnd(input: { - text: string; - start: number; - open: string; - close: string; - rejectWhitespace: boolean; -}): number | null { - const { text, start, open, close, rejectWhitespace } = input; - let depth = 1; - let cursor = start; +function isSupportedLinkDestination(value: string): boolean { + return /^(?:https?:\/\/.+|mailto:.+)/i.test(value); +} - while (cursor < text.length) { +function buildDelimiterIndex(text: string): DelimiterIndex { + const squareBracketEnds = new Map(); + const parenthesisEnds = new Map(); + const angleBracketEnds = new Map(); + const escaped = new Uint8Array(text.length); + const squareStack: number[] = []; + const parenthesisStack: number[] = []; + let openAngleBracket: number | undefined; + let precedingBackslashes = 0; + + for (let cursor = 0; cursor < text.length; cursor++) { const char = text[cursor]!; - if (char === "\\" && cursor + 1 < text.length) { - cursor += 2; + if (char === "\\") { + precedingBackslashes++; continue; } - if (char === "\n" || (rejectWhitespace && /\s/.test(char))) { - return null; + + const isEscaped = precedingBackslashes % 2 === 1; + precedingBackslashes = 0; + if (isEscaped) { + escaped[cursor] = 1; + } + + if (char === "\n") { + squareStack.length = 0; + parenthesisStack.length = 0; + openAngleBracket = undefined; + continue; + } + if (/\s/.test(char)) { + parenthesisStack.length = 0; + openAngleBracket = undefined; + } + if (isEscaped) { + continue; + } + + if (char === "[") { + squareStack.push(cursor); + } else if (char === "]") { + const start = squareStack.pop(); + if (start != null) { + squareBracketEnds.set(start, cursor); + } } - if (char === open) { - depth++; - } else if (char === close) { - depth--; - if (depth === 0) { - return cursor; + + if (char === "(") { + parenthesisStack.push(cursor); + } else if (char === ")") { + const start = parenthesisStack.pop(); + if (start != null) { + parenthesisEnds.set(start, cursor); } } - cursor++; + + if (char === "<") { + openAngleBracket = cursor; + } else if (char === ">" && openAngleBracket != null) { + angleBracketEnds.set(openAngleBracket, cursor); + openAngleBracket = undefined; + } } - return null; + return { + codeDelimiters: buildCodeDelimiterIndex(text, escaped), + squareBracketEnds, + parenthesisEnds, + angleBracketEnds, + escaped, + }; } -function countBacktickRun(text: string, start: number): number { - let cursor = start; - while (text[cursor] === "`") { - cursor++; +function buildCodeDelimiterIndex(text: string, escaped: Uint8Array): Map { + const runs: { start: number; end: number }[] = []; + let cursor = 0; + while (cursor < text.length) { + if (text[cursor] !== "`") { + cursor++; + continue; + } + + const rawStart = cursor; + while (text[cursor] === "`") { + cursor++; + } + const start = escaped[rawStart] === 1 ? rawStart + 1 : rawStart; + if (start < cursor) { + runs.push({ start, end: cursor }); + } } - return cursor - start; -} -function isEscaped(text: string, index: number): boolean { - let slashCount = 0; - for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { - slashCount++; + const codeDelimiters = new Map(); + const nextRunByLength = new Map(); + for (let idx = runs.length - 1; idx >= 0; idx--) { + const run = runs[idx]!; + const length = run.end - run.start; + const closer = nextRunByLength.get(length); + if (closer) { + codeDelimiters.set(run.start, { + openerEnd: run.end, + closerStart: closer.start, + end: closer.end, + }); + } + nextRunByLength.set(length, run); } - return slashCount % 2 === 1; + + return codeDelimiters; } function unescapeMarkdownPunctuation(value: string): string { @@ -217,3 +350,7 @@ function isAsciiPunctuation(char: string): boolean { (code >= 0x7b && code <= 0x7e) ); } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/slack/rich-text.ts b/src/slack/rich-text.ts index 385516a..aee169d 100644 --- a/src/slack/rich-text.ts +++ b/src/slack/rich-text.ts @@ -1,4 +1,8 @@ -import { parseCodeSpanAt, parseMarkdownLinkAt } from "./markdown-inline.ts"; +import { + protectMarkdownInline, + restoreProtectedMarkdownLiterals, + type ProtectedMarkdownInline, +} from "./markdown-inline.ts"; type InlineStyle = { bold?: true; italic?: true; strike?: true; code?: true }; @@ -48,58 +52,13 @@ const BLOCKQUOTE_RE = /^> (.*)$/; * and [label](url). */ export function parseInlineElements(text: string): InlineElement[] { - const protectedInline = protectInlineCodeAndLinks(text); + const protectedInline = protectMarkdownInline(text); return parseProtectedInlineElements(protectedInline.text, protectedInline); } -type ProtectedInlineToken = - | { type: "code"; content: string } - | { type: "link"; label: string; url: string }; - -type ProtectedInlineContext = { - text: string; - tokens: ProtectedInlineToken[]; - marker: string; - suffix: string; -}; - -function protectInlineCodeAndLinks(text: string): ProtectedInlineContext { - let marker = "\uE000"; - while (text.includes(marker)) { - marker += "\uE000"; - } - const suffix = "\uE001"; - const tokens: ProtectedInlineToken[] = []; - let protectedText = ""; - let cursor = 0; - - while (cursor < text.length) { - const codeSpan = parseCodeSpanAt(text, cursor); - if (codeSpan) { - tokens.push({ type: "code", content: codeSpan.content }); - protectedText += `${marker}${tokens.length - 1}${suffix}`; - cursor = codeSpan.end; - continue; - } - - const link = parseMarkdownLinkAt(text, cursor); - if (link) { - tokens.push({ type: "link", label: link.label, url: link.url }); - protectedText += `${marker}${tokens.length - 1}${suffix}`; - cursor = link.end; - continue; - } - - protectedText += text[cursor]; - cursor++; - } - - return { text: protectedText, tokens, marker, suffix }; -} - function parseProtectedInlineElements( text: string, - context: ProtectedInlineContext, + context: ProtectedMarkdownInline, ): InlineElement[] { const { tokens, marker, suffix } = context; const elements: InlineElement[] = []; @@ -185,13 +144,26 @@ function parseProtectedInlineElements( range: broadcastToken as "here" | "channel" | "everyone", }); } else if (linkUrl != null && linkText != null && isSlackManualLinkUrl(linkUrl)) { - elements.push({ type: "link", url: linkUrl, text: linkText }); + elements.push({ + type: "link", + url: restoreProtectedMarkdownLiterals(linkUrl, context), + text: restoreProtectedMarkdownLiterals(linkText, context), + }); } else if (linkUrl != null && linkText != null) { - elements.push({ type: "text", text: `<${linkUrl}|${linkText}>` }); + elements.push({ + type: "text", + text: `<${restoreProtectedMarkdownLiterals(linkUrl, context)}|${restoreProtectedMarkdownLiterals(linkText, context)}>`, + }); } else if (bareUrl != null && isSlackManualLinkUrl(bareUrl)) { - elements.push({ type: "link", url: bareUrl }); + elements.push({ + type: "link", + url: restoreProtectedMarkdownLiterals(bareUrl, context), + }); } else if (bareUrl != null) { - elements.push({ type: "text", text: `<${bareUrl}>` }); + elements.push({ + type: "text", + text: `<${restoreProtectedMarkdownLiterals(bareUrl, context)}>`, + }); } else if (bareUserId != null) { elements.push({ type: "user", user_id: bareUserId }); } else if (bareBroadcast != null) { diff --git a/test/drafts.test.ts b/test/drafts.test.ts index a488b5c..c8d3c60 100644 --- a/test/drafts.test.ts +++ b/test/drafts.test.ts @@ -110,6 +110,23 @@ describe("draftTextToBlocks", () => { }, ]); }); + + test("does not expose protected markers in Slack link labels", () => { + expect(draftTextToBlocks("")).toEqual([ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "link", url: "https://e.test", text: "`code`" }, + { type: "text", text: "\n" }, + ], + }, + ], + }, + ]); + }); }); describe("parseDraftRecord", () => { diff --git a/test/format-outbound.test.ts b/test/format-outbound.test.ts index 1b9e5b2..fa6e268 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -81,6 +81,13 @@ describe("formatOutboundSlackText", () => { expect(formatOutboundSlackText(input)).toBe(input); }); + test("handles bracket-heavy malformed input without repeated suffix scans", () => { + const input = "[".repeat(40_000); + const startedAt = performance.now(); + expect(formatOutboundSlackText(input)).toBe(input); + expect(performance.now() - startedAt).toBeLessThan(500); + }); + test("does not promote email-like or mid-word @", () => { expect(formatOutboundSlackText("mail me at user@Udomain.com")).toBe( "mail me at user@Udomain.com", diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index 527c412..61fbc3c 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -100,6 +100,12 @@ describe("parseInlineElements", () => { ]); }); + test("Slack link labels do not expose protected inline markers", () => { + expect(parseInlineElements("")).toEqual([ + { type: "link", url: "https://e.test", text: "`code`" }, + ]); + }); + test("non-url angle bracket text is preserved as text", () => { expect(parseInlineElements("Use ")).toEqual([ { type: "text", text: "Use " }, @@ -333,6 +339,16 @@ describe("textToRichTextBlocks", () => { ]); }); + test("Slack link labels in list items do not expose protected inline markers", () => { + const result = textToRichTextBlocks("- ")!; + const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { + elements: { elements: unknown[] }[]; + }; + expect(list.elements[0]!.elements).toEqual([ + { type: "link", url: "https://e.test", text: "`code`" }, + ]); + }); + test("code block is preserved", () => { const result = textToRichTextBlocks("- Item\n```\ncode here\n```")!; expect(result).not.toBeNull(); From 483fa0a104abdd2c965b8b2c3b15c6bdd4134016 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 16:08:07 -0700 Subject: [PATCH 4/6] fix(message): accept escaped link schemes --- src/slack/markdown-inline.ts | 2 +- test/format-outbound.test.ts | 4 ++++ test/rich-text.test.ts | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/slack/markdown-inline.ts b/src/slack/markdown-inline.ts index 112274a..b574372 100644 --- a/src/slack/markdown-inline.ts +++ b/src/slack/markdown-inline.ts @@ -203,7 +203,7 @@ function parseLinkDestination( } const value = text.slice(valueStart, valueEnd); - if (!isSupportedLinkDestination(value)) { + if (!isSupportedLinkDestination(unescapeMarkdownPunctuation(value))) { return null; } return { value, end }; diff --git a/test/format-outbound.test.ts b/test/format-outbound.test.ts index fa6e268..02884a4 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -70,6 +70,10 @@ describe("formatOutboundSlackText", () => { expect(formatOutboundSlackText("[Example](HTTPS://E.TEST)")).toBe(""); }); + test("handles escaped punctuation in link schemes", () => { + expect(formatOutboundSlackText("[Example](https\\://e.test)")).toBe(""); + }); + test("handles escaped and nested brackets in link labels", () => { expect(formatOutboundSlackText("[A \\] [nested]](https://e.test)")).toBe( "", diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index 61fbc3c..a299144 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -100,6 +100,12 @@ describe("parseInlineElements", () => { ]); }); + test("Markdown links support escaped punctuation in schemes", () => { + expect(parseInlineElements("[Email](mailto\\:x@e.test)")).toEqual([ + { type: "link", url: "mailto:x@e.test", text: "Email" }, + ]); + }); + test("Slack link labels do not expose protected inline markers", () => { expect(parseInlineElements("")).toEqual([ { type: "link", url: "https://e.test", text: "`code`" }, From 975a3d429978e8afe8409b406ecc810e55358aaa Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 16:38:30 -0700 Subject: [PATCH 5/6] fix(message): preserve protected Markdown literals --- src/slack/format-outbound.ts | 2 +- src/slack/markdown-inline.ts | 20 +++++++++++--------- test/drafts.test.ts | 21 +++++++++++++++++++++ test/format-outbound.test.ts | 17 +++++++++++++++++ test/message-send.test.ts | 17 +++++++++++++++++ test/rich-text.test.ts | 26 ++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/slack/format-outbound.ts b/src/slack/format-outbound.ts index a320221..bafded3 100644 --- a/src/slack/format-outbound.ts +++ b/src/slack/format-outbound.ts @@ -26,7 +26,7 @@ export function formatOutboundSlackText(text: string): string { // Protect already-formatted Slack tokens so `<`/`>` inside them aren't escaped. const stash: string[] = []; out = out.replace( - /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:https?:\/\/|mailto:)[^>]+)>/gi, + /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:[Hh][Tt][Tt][Pp][Ss]?:\/\/|[Mm][Aa][Ii][Ll][Tt][Oo]:)[^>]+)>/g, (m) => { stash.push(m); return `\u0000${stash.length - 1}\u0000`; diff --git a/src/slack/markdown-inline.ts b/src/slack/markdown-inline.ts index b574372..289eead 100644 --- a/src/slack/markdown-inline.ts +++ b/src/slack/markdown-inline.ts @@ -298,26 +298,28 @@ function buildCodeDelimiterIndex(text: string, escaped: Uint8Array): Map(); const nextRunByLength = new Map(); for (let idx = runs.length - 1; idx >= 0; idx--) { const run = runs[idx]!; - const length = run.end - run.start; - const closer = nextRunByLength.get(length); - if (closer) { - codeDelimiters.set(run.start, { + // Outside a code span, a backslash escapes the first backtick in a run, + // so only the remainder can open a span. Inside a span, backslashes are + // literal: the complete raw run remains eligible to close an earlier + // opener. + const openerStart = escaped[run.start] === 1 ? run.start + 1 : run.start; + const openerLength = run.end - openerStart; + const closer = nextRunByLength.get(openerLength); + if (openerLength > 0 && closer) { + codeDelimiters.set(openerStart, { openerEnd: run.end, closerStart: closer.start, end: closer.end, }); } - nextRunByLength.set(length, run); + nextRunByLength.set(run.end - run.start, run); } return codeDelimiters; diff --git a/test/drafts.test.ts b/test/drafts.test.ts index c8d3c60..afe4c80 100644 --- a/test/drafts.test.ts +++ b/test/drafts.test.ts @@ -111,6 +111,27 @@ describe("draftTextToBlocks", () => { ]); }); + test("keeps links inside code spans whose closers follow a backslash", () => { + expect(draftTextToBlocks("`[link](https://e.test)\\`")).toEqual([ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { + type: "text", + text: "[link](https://e.test)\\", + style: { code: true }, + }, + { type: "text", text: "\n" }, + ], + }, + ], + }, + ]); + }); + test("does not expose protected markers in Slack link labels", () => { expect(draftTextToBlocks("")).toEqual([ { diff --git a/test/format-outbound.test.ts b/test/format-outbound.test.ts index 02884a4..a8c3246 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -85,6 +85,23 @@ describe("formatOutboundSlackText", () => { expect(formatOutboundSlackText(input)).toBe(input); }); + test("treats backtick runs preceded by a backslash as code-span closers", () => { + const input = "``[link](https://e.test)\\``"; + expect(formatOutboundSlackText(input)).toBe(input); + }); + + test("matches protected URL schemes case-insensitively without accepting lowercase entity IDs", () => { + expect(formatOutboundSlackText(" ")).toBe( + " ", + ); + expect(formatOutboundSlackText("<@u123456a> <#c12345678> ")).toBe( + "<@u123456a> <#c12345678> <!subteam^s12345678|@team>", + ); + expect(formatOutboundSlackText("<@U123456a> <#C123456a> ")).toBe( + "<@U123456a> <#C123456a> <!subteam^S123456a|@team>", + ); + }); + test("handles bracket-heavy malformed input without repeated suffix scans", () => { const input = "[".repeat(40_000); const startedAt = performance.now(); diff --git a/test/message-send.test.ts b/test/message-send.test.ts index 236d856..a185a5a 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -616,6 +616,23 @@ describe("sendMessage", () => { expect(calls[0]?.params.blocks).toBeUndefined(); }); + test("keeps links inside backslash-terminated code spans and escapes lowercase entity IDs", async () => { + const calls: { method: string; params: Record }[] = []; + const ctx = createContext(calls); + + await sendMessage({ + ctx, + targetInput: "C12345678", + text: "`[link](https://e.test)\\` <@u123456a> <#c12345678> ", + options: {}, + }); + + expect(calls[0]?.params.text).toBe( + "`[link](https://e.test)\\` <@u123456a> <#c12345678> <!subteam^s12345678>", + ); + expect(calls[0]?.params.blocks).toBeUndefined(); + }); + test("converts Markdown links in lists to rich-text link blocks", async () => { const calls: { method: string; params: Record }[] = []; const ctx = createContext(calls); diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index a299144..ebc43e0 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -94,6 +94,12 @@ describe("parseInlineElements", () => { ]); }); + test("backslashes before closing backtick runs stay inside code spans", () => { + expect(parseInlineElements("`[link](https://e.test)\\`")).toEqual([ + { type: "text", text: "[link](https://e.test)\\", style: { code: true } }, + ]); + }); + test("Markdown links support uppercase schemes and nested labels", () => { expect(parseInlineElements("[A \\] [nested]](HTTPS://E.TEST)")).toEqual([ { type: "link", url: "https://E.TEST", text: "A ] [nested]" }, @@ -146,6 +152,16 @@ describe("parseInlineElements", () => { { type: "usergroup", usergroup_id: "S12345678" }, ]); }); + + test("lowercase Slack entity IDs remain literal text", () => { + expect(parseInlineElements("<@u123456a> <#c12345678> ")).toEqual([ + { type: "text", text: "<@u123456a>" }, + { type: "text", text: " " }, + { type: "text", text: "<#c12345678>" }, + { type: "text", text: " " }, + { type: "text", text: "" }, + ]); + }); }); describe("textToRichTextBlocks", () => { @@ -345,6 +361,16 @@ describe("textToRichTextBlocks", () => { ]); }); + test("backslashes before code-span closers do not activate links in list items", () => { + const result = textToRichTextBlocks("- `[link](https://e.test)\\`")!; + const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { + elements: { elements: unknown[] }[]; + }; + expect(list.elements[0]!.elements).toEqual([ + { type: "text", text: "[link](https://e.test)\\", style: { code: true } }, + ]); + }); + test("Slack link labels in list items do not expose protected inline markers", () => { const result = textToRichTextBlocks("- ")!; const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { From 047279e4ff6986eb066d0e8d2ec32a14c5f81b24 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Mon, 21 Sep 2026 15:15:53 -0700 Subject: [PATCH 6/6] fix(message): link bare URLs in rich text --- README.md | 2 +- skills/agent-slack/SKILL.md | 2 +- src/slack/rich-text.ts | 39 ++++++++++++++++++++++++++++++++++++- test/message-send.test.ts | 39 +++++++++++++++++++++++++++++++++++++ test/rich-text.test.ts | 31 +++++++++++++++++++++++++++-- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 091d690..51bbe89 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ agent-slack message edit "#general" "Updated text" --workspace "myteam" --ts "17 agent-slack message delete "#general" --workspace "myteam" --ts "1770165109.628379" ``` -`message edit` and ordinary `message send` calls normalize inline Markdown links and convert bullet/numbered lists to Slack native rich text. `message send --blocks` uses the supplied blocks instead, while `message send --attach` sends its initial comment as plain text without automatic list conversion. Links may use Markdown (`[PR #42](https://example.com/pull/42)`) or Slack (``) syntax. Inside auto-converted lists, links, inline mentions, broadcasts, emoji shortcodes, and `<#C...>` channel references become Slack elements. +`message edit` and ordinary `message send` calls normalize links and convert bullet/numbered lists to Slack native rich text. `message send --blocks` uses the supplied blocks instead, while `message send --attach` sends its initial comment as plain text without automatic list conversion. Links may be bare HTTP(S) URLs or use Markdown (`[PR #42](https://example.com/pull/42)`) or Slack (``) syntax. Inside auto-converted lists, links, inline mentions, broadcasts, emoji shortcodes, and `<#C...>` channel references become Slack elements. Send options for `message send`: diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index 20227d2..132f40f 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -37,7 +37,7 @@ Named `later remind --in` values such as `tomorrow` or `monday` also use the exe Use `--no-unfurl` with `message send` or `message compose` when the user wants Slack link and media previews suppressed. It cannot be combined with `message send --attach`. -Ordinary `message send` and `message edit` calls normalize `[label](URL)` and `` links and auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. +Ordinary `message send` and `message edit` calls normalize bare HTTP(S), `[label](URL)`, and `` links and auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. `create` and `update` accept repeatable `--attach `; on `update` the files are added to the draft's existing attachments rather than replacing them. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd). diff --git a/src/slack/rich-text.ts b/src/slack/rich-text.ts index aee169d..7faf23e 100644 --- a/src/slack/rich-text.ts +++ b/src/slack/rich-text.ts @@ -49,7 +49,7 @@ const BLOCKQUOTE_RE = /^> (.*)$/; * Parse mrkdwn inline formatting into Slack rich_text inline elements. * * Handles: *bold*, _italic_, ~strike~, `code`, :emoji:, , , - * and [label](url). + * bare HTTP(S) URLs, and [label](url). */ export function parseInlineElements(text: string): InlineElement[] { const protectedInline = protectMarkdownInline(text); @@ -75,6 +75,7 @@ function parseProtectedInlineElements( "here|channel|everyone)(?:\\|[^>]*)?>", "<(?[^>|]+)\\|(?[^>]+)>", "<(?[^>|]+)>", + "(?[Hh][Tt][Tt][Pp][Ss]?:\\/\\/[^\\s<>]+)", "(?:^|(?<=[^A-Za-z0-9_]))@(?[UWB][A-Z0-9]{6,})\\b", "(?:^|(?<=[^A-Za-z0-9_]))@(?here|channel|everyone)\\b", ].join("|"), @@ -108,6 +109,7 @@ function parseProtectedInlineElements( linkUrl, linkText, bareUrl, + plainUrl, bareUserId, bareBroadcast, } = groups; @@ -164,6 +166,10 @@ function parseProtectedInlineElements( type: "text", text: `<${restoreProtectedMarkdownLiterals(bareUrl, context)}>`, }); + } else if (plainUrl != null) { + const { url, trailingText } = splitBareUrlTrailingText(plainUrl); + elements.push({ type: "link", url }); + pushText(trailingText); } else if (bareUserId != null) { elements.push({ type: "user", user_id: bareUserId }); } else if (bareBroadcast != null) { @@ -200,6 +206,37 @@ function isSlackManualLinkUrl(value: string): boolean { return /^(?:https?:\/\/|mailto:)/i.test(value); } +function splitBareUrlTrailingText(value: string): { url: string; trailingText: string } { + let urlEnd = value.length; + + while (urlEnd > 0) { + const candidate = value.slice(0, urlEnd); + const lastCharacter = candidate.at(-1)!; + if (/[.,!?;:]/.test(lastCharacter)) { + urlEnd--; + continue; + } + + const openingCharacter = ({ ")": "(", "]": "[", "}": "{" } as const)[lastCharacter]; + if (openingCharacter != null) { + const openingCount = countCharacter(candidate, openingCharacter); + const closingCount = countCharacter(candidate, lastCharacter); + if (closingCount > openingCount) { + urlEnd--; + continue; + } + } + + break; + } + + return { url: value.slice(0, urlEnd), trailingText: value.slice(urlEnd) }; +} + +function countCharacter(value: string, character: string): number { + return value.split(character).length - 1; +} + /** * Convert mrkdwn text to Slack rich_text blocks when bullet or numbered * lists are detected. Returns `null` when the text contains no lists, diff --git a/test/message-send.test.ts b/test/message-send.test.ts index a185a5a..6a79872 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -658,6 +658,45 @@ describe("sendMessage", () => { ]); }); + test("converts bare URLs in list messages to rich-text link blocks", async () => { + const calls: { method: string; params: Record }[] = []; + const ctx = createContext(calls); + + await sendMessage({ + ctx, + targetInput: "C12345678", + text: "I got another PR in: https://example.com/pull/42\n\n- Passenger one", + options: {}, + }); + + expect(calls[0]?.method).toBe("chat.postMessage"); + expect(calls[0]?.params.blocks).toEqual([ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "I got another PR in: " }, + { type: "link", url: "https://example.com/pull/42" }, + { type: "text", text: "\n" }, + ], + }, + { + type: "rich_text_list", + style: "bullet", + elements: [ + { + type: "rich_text_section", + elements: [{ type: "text", text: "Passenger one" }], + }, + ], + }, + ], + }, + ]); + }); + test("--blocks: errors when an array element is not an object", async () => { const calls: { method: string; params: Record }[] = []; const ctx = createContext(calls); diff --git a/test/rich-text.test.ts b/test/rich-text.test.ts index ebc43e0..49ba9c7 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -74,6 +74,29 @@ describe("parseInlineElements", () => { ]); }); + test("bare HTTP URLs are parsed as links", () => { + expect(parseInlineElements("Review https://example.com/pull/42 now")).toEqual([ + { type: "text", text: "Review " }, + { type: "link", url: "https://example.com/pull/42" }, + { type: "text", text: " now" }, + ]); + }); + + test("bare URLs exclude sentence punctuation and unmatched closing delimiters", () => { + expect(parseInlineElements("See (https://example.com/a_(b)), please.")).toEqual([ + { type: "text", text: "See (" }, + { type: "link", url: "https://example.com/a_(b)" }, + { type: "text", text: ")," }, + { type: "text", text: " please." }, + ]); + }); + + test("bare URLs in code spans remain code", () => { + expect(parseInlineElements("`https://example.com/pull/42`")).toEqual([ + { type: "text", text: "https://example.com/pull/42", style: { code: true } }, + ]); + }); + test("Markdown links are parsed as links with text", () => { expect(parseInlineElements("Review [PR #42](https://example.com/pull/42)")).toEqual([ { type: "text", text: "Review " }, @@ -323,9 +346,9 @@ describe("textToRichTextBlocks", () => { ]); }); - test("Slack manual and Markdown links become link elements in list items", () => { + test("Slack manual, Markdown, and bare links become link elements in list items", () => { const result = textToRichTextBlocks( - "- Review \n- Review [PR #43](https://example.com/pull/43)", + "- Review \n- Review [PR #43](https://example.com/pull/43)\n- Review https://example.com/pull/44", )!; const list = result[0]!.elements.find((e) => e.type === "rich_text_list") as { elements: { elements: unknown[] }[]; @@ -338,6 +361,10 @@ describe("textToRichTextBlocks", () => { { type: "text", text: "Review " }, { type: "link", url: "https://example.com/pull/43", text: "PR #43" }, ]); + expect(list.elements[2]!.elements).toEqual([ + { type: "text", text: "Review " }, + { type: "link", url: "https://example.com/pull/44" }, + ]); }); test("links inside emphasized list items retain the emphasis", () => {