Skip to content
Merged
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-special-mentions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": patch
---

Convert Slack special mentions (`<!here>`, `<!channel>`, `<!everyone>`) and user group mentions (`<!subteam^ID|@handle>`) to readable text in inbound messages.
58 changes: 58 additions & 0 deletions packages/adapter-slack/src/format/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,64 @@ describe("Slack format primitives", () => {
);
});

it("normalizes special mentions and user groups", () => {
expect(
slackMrkdwnToMarkdown(
"<!here> <!channel> <!everyone|everyone> <!subteam^S123|@devs> <!subteam^S456>"
)
).toBe("@here @channel @everyone @devs @S456");
});

it.each([
"<!here>",
"<!channel>",
"<!everyone|everyone>",
"<!subteam^S123|@devs>",
"<!subteam^S456>",
])("preserves %s inside code while converting surrounding mentions", (token) => {
expect(slackMrkdwnToMarkdown(`<!here> \`${token}\` <!channel>`)).toBe(
`@here \`${token}\` @channel`
);
expect(
slackMrkdwnToMarkdown(`<!here> \`\`\`${token}\`\`\` <!channel>`)
).toBe(`@here \n\`\`\`\n${token}\n\`\`\`\n @channel`);
});

it("converts special mentions around multiple inline code spans", () => {
expect(
slackMrkdwnToMarkdown(
"`<!here>` <!channel> `<!everyone>` <!subteam^S123|@devs>"
)
).toBe("`<!here>` @channel `<!everyone>` @devs");
});

it("does not treat an unmatched backtick as a code span", () => {
expect(slackMrkdwnToMarkdown("use ` then <!here>")).toBe(
"use ` then @here"
);
expect(slackMrkdwnToMarkdown("`first\n<!here> `last")).toBe(
"`first\n@here `last"
);
});

it("preserves escaped special mentions", () => {
expect(slackMrkdwnToMarkdown("&lt;!here&gt; <!channel>")).toBe(
"<!here> @channel"
);
});

it("keeps link-label backticks from hiding special mentions", () => {
expect(
slackMrkdwnToMarkdown("<https://example.com|`label> <!here> `open")
).toBe("[`label](https://example.com) @here `open");
});

it("preserves emphasis across inline code", () => {
expect(slackMrkdwnToMarkdown("*before `<!here>` after* <!channel>")).toBe(
"**before `<!here>` after** @channel"
);
});

it("normalizes Slack code fences for CommonMark parsing", () => {
expect(slackMrkdwnToMarkdown("```first line\nsecond line\n```")).toBe(
"```\nfirst line\nsecond line\n```"
Expand Down
42 changes: 41 additions & 1 deletion packages/adapter-slack/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const CONTROL_PATTERN = /[<>|]/;
const DATE_CONTROL_PATTERN = /[\^|>]/;
const SLACK_ID_PATTERN = /^[A-Z0-9_]+$/;
const SLACK_USER_TOKEN_PATTERN = /(?<![<\w])@([A-Z][A-Z0-9_]+)/g;
const SPECIAL_MENTION_PATTERN = /^<!(here|channel|everyone)(?:\|[^<>]*)?>$/;
const LABELED_GROUP_PATTERN = /^<!subteam\^([A-Z0-9_]+)\|@?([^<>]+)>$/;
const GROUP_PATTERN = /^<!subteam\^([A-Z0-9_]+)>$/;
const TEXT_OBJECT_MAX_LENGTH = 3000;
const CODE_FENCE = "```";
const LEADING_WHITESPACE_PATTERN = /^[ \t]+/;
Expand Down Expand Up @@ -138,12 +141,49 @@ function convertSlackTokens(mrkdwn: string): string {
}

function convertMrkdwnText(mrkdwn: string): string {
let markdown = convertSlackTokens(mrkdwn);
let markdown = convertSlackTokens(convertSpecialMentions(mrkdwn));
markdown = markdown.replace(/(?<![_*\\])\*([^*\n]+)\*(?![_*])/g, "**$1**");
markdown = markdown.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "~~$1~~");
return markdown;
}

function convertSpecialMentions(mrkdwn: string): string {
let result = "";
let start = 0;
let cursor = 0;

while (cursor < mrkdwn.length) {
if (mrkdwn.startsWith(CODE_FENCE, cursor)) {
cursor += CODE_FENCE.length;
continue;
}
if (mrkdwn[cursor] === "`") {
const end = findInlineCodeEnd(mrkdwn, cursor);
cursor = end === -1 ? cursor + 1 : end;
continue;
}
if (mrkdwn[cursor] !== "<") {
cursor += 1;
continue;
}
const end = findAngleTokenEnd(mrkdwn, cursor);
if (end === -1) {
cursor += 1;
continue;
}
const token = mrkdwn
.slice(cursor, end)
.replace(SPECIAL_MENTION_PATTERN, "@$1")
.replace(LABELED_GROUP_PATTERN, "@$2")
.replace(GROUP_PATTERN, "@$1");
result += mrkdwn.slice(start, cursor) + token;
start = end;
cursor = end;
}

return result + mrkdwn.slice(start);
}

/**
* Slack treats text immediately after an opening fence as code, while
* CommonMark treats it as the fence's info string. Rewrite each paired
Expand Down
85 changes: 84 additions & 1 deletion packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,89 @@ describe("parseMessage", () => {
expect(message.author.isMe).toBe(false);
});

it("converts special mentions to readable text", () => {
const message = adapter.parseMessage({
type: "message",
user: "U123",
channel: "C456",
text: "<!here> and <!subteam^S0123456789|@devs>",
ts: "1234567890.123456",
});

expect(message.text).toBe("@here and @devs");
expect(message.formatted.children.map((node) => node.type)).toEqual([
"paragraph",
]);
});

it("preserves special mention tokens in an inbound inline code span", () => {
const text = "<!here> <!channel> <!everyone> <!subteam^S123>";
const message = adapter.parseMessage({
type: "message",
user: "U123",
channel: "D456",
channel_type: "im",
text: `review code \`${text}\``,
ts: "1234567890.123456",
blocks: [
{
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [
{ type: "text", text: "review code " },
{ type: "text", text, style: { code: true } },
],
},
],
},
],
});

expect(message.text).toBe(`review code ${text}`);
expect(message.formatted.children).toEqual([
expect.objectContaining({
type: "paragraph",
children: [
expect.objectContaining({ type: "text", value: "review code " }),
expect.objectContaining({ type: "inlineCode", value: text }),
],
}),
]);
expect(message.isMention).toBe(false);
});

it("preserves special mention tokens in an inbound code block", () => {
const text = "review fence <!here> <!channel> <!everyone>";
const message = adapter.parseMessage({
type: "message",
user: "U123",
channel: "D456",
channel_type: "im",
text: `\`\`\`${text}\`\`\``,
ts: "1234567890.123456",
blocks: [
{
type: "rich_text",
elements: [
{
type: "rich_text_preformatted",
elements: [{ type: "text", text }],
border: 0,
},
],
},
],
});

expect(message.text).toBe(text);
expect(message.formatted.children).toEqual([
expect.objectContaining({ type: "code", value: text }),
]);
expect(message.isMention).toBe(false);
});

it("parses a bot message", () => {
const event = {
type: "message",
Expand Down Expand Up @@ -1626,7 +1709,7 @@ describe("parseMessage", () => {
});

// Cell tokens are rendered by the same mrkdwn converter as body text
expect(message.text).toBe("#C789 <!subteam^S789> July 11 #ff0000");
expect(message.text).toBe("#C789 @S789 July 11 #ff0000");
});

it("formats date cells from the timestamp when no fallback is present", () => {
Expand Down
Loading