Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slack-fallback-link-destinations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": patch
---

Preserve explicit Slack link destinations and labels in the incoming message AST when Markdown parsing limits are exceeded. Keep the existing plain-text display and parsing limits.
2 changes: 2 additions & 0 deletions apps/docs/content/adapters/official/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@ Charts that violate Slack's constraints (50-character title, 12 segments/series,

Bulleted and numbered lists created in Slack's composer become nested mdast `list` nodes in `message.formatted`. In `message.text`, list items are separated by newlines without bullet markers. Literal list text such as `---` and `[x]` stays literal, and paragraphs in subsequent rich text blocks stay outside the list.

When incoming mrkdwn exceeds the Markdown parsing limits, the adapter keeps formatting characters as literal text. Explicit Slack links such as `<https://example.com/receipt|receipt>` still become `link` nodes in `message.formatted`, preserving the destination and label. `message.text` contains the label, with Slack mentions rendered as text and entities decoded.

If a message with a list contains unsupported rich text elements, such as a `message_mention`, the adapter uses Slack's `event.text` fallback for the body. This preserves the fallback's content, but list structure is unavailable for those messages.

## Inbound attachments
Expand Down
84 changes: 81 additions & 3 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16416,6 +16416,31 @@ describe("inbound markdown budget", { timeout: 20_000 }, () => {
}
});

it("preserves link destinations above the emphasis limit on both incoming paths", async () => {
const prefix = "*".repeat(2049);
for (const message of await parseBoth({
text: `${prefix} <https://example.com/a|receipt>`,
})) {
expect(message.text).toBe(`${prefix} receipt`);
expect(message.formatted).toEqual({
type: "root",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: `${prefix} ` },
{
type: "link",
url: "https://example.com/a",
children: [{ type: "text", value: "receipt" }],
},
],
},
],
});
}
});

it("keeps oversized history messages unparsed", async () => {
const { adapter } = await createAdapter();
mockClientMethod(
Expand Down Expand Up @@ -16679,7 +16704,23 @@ describe("inbound markdown budget", { timeout: 20_000 }, () => {
for (const message of await parseBoth({ text: input })) {
expect(message.formatted).toEqual({
type: "root",
children: [unparsed(expected)],
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: "Tom & Jerry @alice joined #general (C789), see ",
},
{
type: "link",
url: "https://example.com",
children: [{ type: "text", value: "the docs" }],
},
{ type: "text", value: ` <now> *as typed*${FILLER}` },
],
},
],
});
expect(message.text).toBe(expected);
}
Expand Down Expand Up @@ -16711,7 +16752,26 @@ describe("inbound markdown budget", { timeout: 20_000 }, () => {
const expected = `@devs mail https://example.com${FILLER}`;
expect(messages[0]?.formatted).toEqual({
type: "root",
children: [unparsed(expected)],
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "@devs " },
{
type: "link",
url: "mailto:a@example.com",
children: [{ type: "text", value: "mail" }],
},
{ type: "text", value: " " },
{
type: "link",
url: "https://example.com",
children: [{ type: "text", value: "https://example.com" }],
},
{ type: "text", value: FILLER },
],
},
],
});
expect(messages[0]?.text).toBe(expected);
});
Expand All @@ -16725,7 +16785,25 @@ describe("inbound markdown budget", { timeout: 20_000 }, () => {
for (const message of await parseBoth({ text: input })) {
expect(message.formatted).toEqual({
type: "root",
children: [unparsed(expected)],
children: [
{
type: "paragraph",
children: [
{ type: "text", value: "Plain *bold* words and " },
...Array.from({ length: 330 }, (_, index) => [
{
type: "link",
url: "https://example.com",
children: [{ type: "text", value: "a link" }],
},
{
type: "text",
value: index === 329 ? ".\n" : ".\nPlain *bold* words and ",
},
]).flat(),
],
},
],
});
expect(message.text).toBe(expected);
}
Expand Down
96 changes: 95 additions & 1 deletion packages/adapter-slack/src/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,104 @@
import { toPlainText } from "chat";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { SlackFormatConverter } from "./markdown";

describe("SlackFormatConverter", () => {
const converter = new SlackFormatConverter();

describe("incoming fallback links", () => {
it.each([
"*".repeat(2049),
"]".repeat(129),
"a".repeat(16385),
"<".repeat(20000),
"<unfinished\n".repeat(2000),
])("preserves explicit links without parsing over-budget input %#", (prefix) => {
const parsing = new SlackFormatConverter();
const parse = vi.spyOn(parsing, "toAst");
const result = parsing.parseIncoming(
`${prefix} <https://example.com/a?a=1&amp;b=2|領収書>`
);
expect(parse).not.toHaveBeenCalled();
expect(result.text).toBe(`${prefix} 領収書`);
expect(result.formatted).toEqual({
type: "root",
children: [
{
type: "paragraph",
children: [
{ type: "text", value: `${prefix} ` },
{
type: "link",
url: "https://example.com/a?a=1&b=2",
children: [{ type: "text", value: "領収書" }],
},
],
},
],
});
});
});

it("keeps parsing at the emphasis boundary", () => {
const parsing = new SlackFormatConverter();
const parse = vi.spyOn(parsing, "toAst");
parsing.parseIncoming(`${"*".repeat(2048)} <https://example.com|receipt>`);
expect(parse).toHaveBeenCalledOnce();
});

it("preserves links when parsing overflows the stack", () => {
const parsing = new SlackFormatConverter();
vi.spyOn(parsing, "toAst").mockImplementation(() => {
throw new RangeError("stack");
});
expect(
parsing.parseIncoming("<https://example.com|receipt>").formatted
).toMatchObject({
children: [{ children: [{ type: "link", url: "https://example.com" }] }],
});
});

it("preserves fallback labels, mentions, entities, and malformed tokens", () => {
const prefix = "*".repeat(2049);
const input = `${prefix} <@U_FAKE|sam> <#C_FAKE|team> <!here> &amp; <https://example.com|A &amp; B> <mailto:test@example.com> <tel:+15550000000|call> <https://example.com|> <javascript:alert(1)|bad> &lt;https://example.com&gt;`;
const result = converter.parseIncoming(input);
expect(result.text).toBe(
`${prefix} @sam #team (C_FAKE) @here & A & B test@example.com call <https://example.com|> <javascript:alert(1)|bad> <https://example.com>`
);
expect(toPlainText(result.formatted)).toBe(result.text);
const paragraph = result.formatted.children[0];
expect(paragraph?.type).toBe("paragraph");
if (paragraph?.type === "paragraph") {
expect(
paragraph.children
.filter((node) => node.type === "link")
.map((node) => node.url)
).toEqual([
"https://example.com",
"mailto:test@example.com",
"tel:+15550000000",
]);
}
});

it("preserves links in parts when the combined message exceeds the budget", () => {
const part = "<https://example.com|receipt>";
const parse = converter.incomingPartsParser(["*".repeat(2049), part]);
expect(parse(part)).toMatchObject({
children: [
{
children: [
{
type: "link",
url: "https://example.com",
children: [{ type: "text", value: "receipt" }],
},
],
},
],
});
});

describe("toMarkdown (mrkdwn -> markdown)", () => {
it.each([
"cost is 2 * 3 seats and 4 * 5 licenses",
Expand Down
51 changes: 43 additions & 8 deletions packages/adapter-slack/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ import {
isStrongNode,
isTableNode,
isTextNode,
type Link,
parseMarkdown,
type Root,
stringifyMarkdown,
type Text,
tableToAscii,
} from "chat";
import { slackMrkdwnToMarkdown, unescapeSlackText } from "./format";
Expand Down Expand Up @@ -96,12 +98,17 @@ function controlTokenText(token: string): string | undefined {
}

/**
* Render mrkdwn as plain text without parsing it as markdown: control tokens
* Render mrkdwn as text and explicit links without parsing markdown: control tokens
* become the text `toAst` renders for them and entities are decoded, while
* formatting characters stay as typed. Runs in linear time.
*/
function slackMrkdwnToPlainText(mrkdwn: string): string {
let result = "";
function slackMrkdwnFallback(mrkdwn: string): {
formatted: Root;
text: string;
} {
const children: (Text | Link)[] = [];
const values: string[] = [];
let pending = "";
let start = 0;
let cursor = mrkdwn.indexOf("<");
while (cursor !== -1) {
Expand All @@ -112,12 +119,37 @@ function slackMrkdwnToPlainText(mrkdwn: string): string {
}
const text = controlTokenText(mrkdwn.slice(cursor + 1, end - 1));
if (text !== undefined) {
result += mrkdwn.slice(start, cursor) + text;
const token = mrkdwn.slice(cursor + 1, end - 1);
const pipe = token.indexOf("|");
const target = pipe === -1 ? token : token.slice(0, pipe);
pending += mrkdwn.slice(start, cursor);
if (LINK_TARGET_PATTERN.test(target)) {
if (pending) {
children.push({ type: "text", value: unescapeSlackText(pending) });
pending = "";
}
children.push({
type: "link",
url: unescapeSlackText(target),
children: [{ type: "text", value: unescapeSlackText(text) }],
});
} else {
pending += text;
}
values.push(mrkdwn.slice(start, cursor), text);
start = end;
}
cursor = mrkdwn.indexOf("<", end);
}
return unescapeSlackText(result + mrkdwn.slice(start));
pending += mrkdwn.slice(start);
if (pending || children.length === 0) {
children.push({ type: "text", value: unescapeSlackText(pending) });
}
values.push(mrkdwn.slice(start));
return {
text: unescapeSlackText(values.join("")),
formatted: { type: "root", children: [{ type: "paragraph", children }] },
};
}

export class SlackFormatConverter extends BaseFormatConverter {
Expand All @@ -140,7 +172,7 @@ export class SlackFormatConverter extends BaseFormatConverter {
* Return a parser for the mrkdwn parts of one message, such as its text,
* layout block text, attachment text, and table cells. The parse budget
* applies to all parts together: when they exceed it, no part is parsed and
* each becomes a single text node. Otherwise each part goes through
* each retains only text and explicit link nodes. Otherwise each part goes through
* `parseIncoming`.
*
* @internal Used by SlackAdapter to parse inbound messages.
Expand Down Expand Up @@ -168,8 +200,11 @@ export class SlackFormatConverter extends BaseFormatConverter {
);
}

protected override incomingFallbackText(mrkdwn: string): string {
return slackMrkdwnToPlainText(mrkdwn);
protected override unparsedIncoming(mrkdwn: string): {
formatted: Root;
text: string;
} {
return slackMrkdwnFallback(mrkdwn);
}

/**
Expand Down