diff --git a/README.md b/README.md index 7fb9c07..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 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 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 c1143e1..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 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 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/format-outbound.ts b/src/slack/format-outbound.ts index e957884..bafded3 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`. * @@ -8,18 +10,23 @@ * `` / `` / `` * * 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 ""; } + // Slack does not understand CommonMark links in message text. Normalize the + // common inline form while leaving images, escaped links, and code alone. + let out = markdownLinksToSlackMrkdwn(text); + // Protect already-formatted Slack tokens so `<`/`>` inside them aren't escaped. const stash: string[] = []; - let out = text.replace( - /<(?:@[UWB][A-Z0-9]+(?:\|[^>]*)?|#[CG][A-Z0-9]+(?:\|[^>]*)?|!subteam\^[A-Z0-9]+(?:\|[^>]*)?|![a-zA-Z]+(?:\|[^>]*)?|(?:https?:\/\/|mailto:)[^>]+)>/g, + out = out.replace( + /<(?:@[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 new file mode 100644 index 0000000..289eead --- /dev/null +++ b/src/slack/markdown-inline.ts @@ -0,0 +1,358 @@ +type ParsedCodeSpan = { + content: string; + end: number; +}; + +type ParsedMarkdownLink = { + label: string; + url: string; + end: number; +}; + +type CodeDelimiter = { + openerEnd: number; + closerStart: number; + end: number; +}; + +type DelimiterIndex = { + codeDelimiters: Map; + squareBracketEnds: Map; + parenthesisEnds: Map; + angleBracketEnds: Map; + escaped: Uint8Array; +}; + +type ParserContext = { + text: string; + index: DelimiterIndex; +}; + +export type MarkdownInlineParser = { + codeSpanAt: (start: number) => ParsedCodeSpan | null; + markdownLinkAt: (start: number) => ParsedMarkdownLink | 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 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; + + 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 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; + } + + protectedText += text[cursor]; + cursor++; + } + + 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 = parser.codeSpanAt(cursor); + if (codeSpan) { + output += text.slice(cursor, codeSpan.end); + cursor = codeSpan.end; + continue; + } + + 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"); + output += `<${url}|${label}>`; + cursor = link.end; + continue; + } + + output += text[cursor]; + cursor++; + } + + return output; +} + +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] === "<") { + valueStart++; + valueEnd = index.angleBracketEnds.get(start); + if (valueEnd == null || text[valueEnd + 1] !== ")") { + return null; + } + end = valueEnd + 2; + } else { + valueEnd = index.parenthesisEnds.get(openingParenthesis); + if (valueEnd == null || valueEnd === start) { + return null; + } + end = valueEnd + 1; + } + + const value = text.slice(valueStart, valueEnd); + if (!isSupportedLinkDestination(unescapeMarkdownPunctuation(value))) { + return null; + } + return { value, end }; +} + +function isSupportedLinkDestination(value: string): boolean { + return /^(?:https?:\/\/.+|mailto:.+)/i.test(value); +} + +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 === "\\") { + precedingBackslashes++; + continue; + } + + 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 === "(") { + parenthesisStack.push(cursor); + } else if (char === ")") { + const start = parenthesisStack.pop(); + if (start != null) { + parenthesisEnds.set(start, cursor); + } + } + + if (char === "<") { + openAngleBracket = cursor; + } else if (char === ">" && openAngleBracket != null) { + angleBracketEnds.set(openAngleBracket, cursor); + openAngleBracket = undefined; + } + } + + return { + codeDelimiters: buildCodeDelimiterIndex(text, escaped), + squareBracketEnds, + parenthesisEnds, + angleBracketEnds, + escaped, + }; +} + +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++; + } + runs.push({ start: rawStart, end: cursor }); + } + + const codeDelimiters = new Map(); + const nextRunByLength = new Map(); + for (let idx = runs.length - 1; idx >= 0; idx--) { + const run = runs[idx]!; + // 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(run.end - run.start, run); + } + + return codeDelimiters; +} + +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) + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/slack/rich-text.ts b/src/slack/rich-text.ts index 2572666..7faf23e 100644 --- a/src/slack/rich-text.ts +++ b/src/slack/rich-text.ts @@ -1,3 +1,9 @@ +import { + protectMarkdownInline, + restoreProtectedMarkdownLiterals, + type ProtectedMarkdownInline, +} from "./markdown-inline.ts"; + type InlineStyle = { bold?: true; italic?: true; strike?: true; code?: true }; type InlineElement = @@ -42,12 +48,39 @@ 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:, , , + * bare HTTP(S) URLs, and [label](url). */ export function parseInlineElements(text: string): InlineElement[] { + const protectedInline = protectMarkdownInline(text); + return parseProtectedInlineElements(protectedInline.text, protectedInline); +} + +function parseProtectedInlineElements( + text: string, + context: ProtectedMarkdownInline, +): 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)(?:\\|[^>]*)?>", + "<(?[^>|]+)\\|(?[^>]+)>", + "<(?[^>|]+)>", + "(?[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("|"), + "g", + ); let lastIndex = 0; let match: RegExpExecArray | null; @@ -62,9 +95,9 @@ export function parseInlineElements(text: string): InlineElement[] { pushText(text.slice(lastIndex, match.index)); } - const [ - , - code, + const groups = match.groups ?? {}; + const { + protectedTokenIndex, emojiName, bold, italic, @@ -76,19 +109,31 @@ export function parseInlineElements(text: string): InlineElement[] { linkUrl, linkText, bareUrl, + plainUrl, 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) { @@ -101,13 +146,30 @@ export function parseInlineElements(text: string): InlineElement[] { 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 (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) { @@ -127,10 +189,54 @@ 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); } +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/drafts.test.ts b/test/drafts.test.ts index 1dfee4d..afe4c80 100644 --- a/test/drafts.test.ts +++ b/test/drafts.test.ts @@ -71,6 +71,83 @@ 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" }, + ], + }, + ], + }, + ]); + }); + + 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([ + { + 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 59eb100..a8c3246 100644 --- a/test/format-outbound.test.ts +++ b/test/format-outbound.test.ts @@ -44,6 +44,71 @@ 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("handles uppercase schemes and canonicalizes them for Slack", () => { + 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( + "", + ); + }); + + test("preserves Markdown links inside multi-backtick code spans", () => { + const input = "``foo ` [link](https://e.test)``"; + 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(); + 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/message-send.test.ts b/test/message-send.test.ts index 12ff5e3..6a79872 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -600,6 +600,103 @@ 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("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); + + 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("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 e619bc6..49ba9c7 100644 --- a/test/rich-text.test.ts +++ b/test/rich-text.test.ts @@ -74,6 +74,73 @@ 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 " }, + { type: "link", url: "https://example.com/pull/42", text: "PR #42" }, + ]); + }); + + 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("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]" }, + ]); + }); + + 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`" }, + ]); + }); + test("non-url angle bracket text is preserved as text", () => { expect(parseInlineElements("Use ")).toEqual([ { type: "text", text: "Use " }, @@ -108,6 +175,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", () => { @@ -269,9 +346,9 @@ describe("textToRichTextBlocks", () => { ]); }); - test("Slack manual links and CommonMark links remain distinct 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[] }[]; @@ -281,7 +358,53 @@ 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" }, + ]); + 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", () => { + 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("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 { + elements: { elements: unknown[] }[]; + }; + expect(list.elements[0]!.elements).toEqual([ + { type: "link", url: "https://e.test", text: "`code`" }, ]); });