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/blue-peaches-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": minor
---

add lightweight Slack formatting primitives subpath
4 changes: 4 additions & 0 deletions packages/adapter-slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"./webhook": {
"types": "./dist/webhook.d.ts",
"import": "./dist/webhook.js"
},
"./format": {
"types": "./dist/format.d.ts",
"import": "./dist/format.js"
}
},
"files": [
Expand Down
18 changes: 18 additions & 0 deletions packages/adapter-slack/src/format/boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";

describe("format import boundary", () => {
it("does not import the full adapter or runtime packages", async () => {
const source = await readFile(new URL("./index.ts", import.meta.url), {
encoding: "utf8",
});

expect(source).not.toContain('from "chat"');
expect(source).not.toContain("from '@chat-adapter/shared'");
expect(source).not.toContain('from "@chat-adapter/shared"');
expect(source).not.toContain('from "@slack/web-api"');
expect(source).not.toContain('from "@slack/socket-mode"');
expect(source).not.toContain('from "../index"');
expect(source).not.toContain("node:");
});
});
117 changes: 117 additions & 0 deletions packages/adapter-slack/src/format/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
createSlackMrkdwn,
createSlackPlainText,
escapeSlackText,
formatSlackChannel,
formatSlackDate,
formatSlackLink,
formatSlackSpecialMention,
formatSlackUser,
formatSlackUserGroup,
linkBareSlackMentions,
markdownBoldToSlackMrkdwn,
slackMrkdwnToMarkdown,
unescapeSlackText,
} from "./index";

describe("Slack format primitives", () => {
it("escapes Slack mrkdwn control characters", () => {
expect(escapeSlackText("a & <b>")).toBe("a &amp; &lt;b&gt;");
});

it("unescapes Slack mrkdwn control characters", () => {
expect(unescapeSlackText("a &amp; &lt;b&gt;")).toBe("a & <b>");
});

it("creates plain_text objects", () => {
expect(createSlackPlainText("hello", { emoji: true })).toEqual({
emoji: true,
text: "hello",
type: "plain_text",
});
});

it("rejects invalid text object lengths", () => {
expect(() => createSlackPlainText("")).toThrow(TypeError);
expect(() => createSlackMrkdwn("x".repeat(3001))).toThrow(TypeError);
});

it("creates mrkdwn objects", () => {
expect(createSlackMrkdwn("*hello*", { verbatim: true })).toEqual({
text: "*hello*",
type: "mrkdwn",
verbatim: true,
});
});

it("formats Slack user mentions", () => {
expect(formatSlackUser("U123")).toBe("<@U123>");
});

it("formats Slack channel mentions", () => {
expect(formatSlackChannel("C123")).toBe("<#C123>");
});

it("formats Slack user group mentions", () => {
expect(formatSlackUserGroup("S123")).toBe("<!subteam^S123>");
});

it("formats Slack special mentions", () => {
expect(formatSlackSpecialMention("here")).toBe("<!here>");
});

it("formats Slack links", () => {
expect(formatSlackLink("https://example.com?a=1&b=2")).toBe(
"<https://example.com?a=1&b=2>"
);
expect(formatSlackLink("https://example.com", "read <this>")).toBe(
"<https://example.com|read &lt;this&gt;>"
);
});

it("rejects unsafe Slack link control characters", () => {
expect(() => formatSlackLink("https://example.com|bad")).toThrow(TypeError);
});

it("formats Slack dates", () => {
expect(formatSlackDate(1_710_000_000, "{date_short}", "Mar 9")).toBe(
"<!date^1710000000^{date_short}|Mar 9>"
);
expect(
formatSlackDate(new Date("2024-03-09T16:00:00.000Z"), "{time}", "4pm", {
link: "https://example.com",
})
).toBe("<!date^1710000000^{time}^https://example.com|4pm>");
});

it("normalizes Slack mrkdwn to Markdown", () => {
expect(
slackMrkdwnToMarkdown(
"Hey <@U123|jane> in <#C123|general>, see <https://example.com|this> and *bold* ~done~"
)
).toBe(
"Hey @jane in #general, see [this](https://example.com) and **bold** ~~done~~"
);
});

it("normalizes bare Slack links to Markdown URLs", () => {
expect(slackMrkdwnToMarkdown("See <https://example.com>")).toBe(
"See https://example.com"
);
});

it("converts basic Markdown bold to Slack mrkdwn bold", () => {
expect(markdownBoldToSlackMrkdwn("The **domain** is example.com")).toBe(
"The *domain* is example.com"
);
});

it("links bare mention-like tokens without touching emails", () => {
expect(linkBareSlackMentions("(cc @U123, @U456)")).toBe(
"(cc <@U123>, <@U456>)"
);
expect(linkBareSlackMentions("@george")).toBe("@george");
expect(linkBareSlackMentions("user@example.com")).toBe("user@example.com");
});
});
162 changes: 162 additions & 0 deletions packages/adapter-slack/src/format/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
export interface SlackPlainTextObject {
emoji?: boolean;
text: string;
type: "plain_text";
}

export interface SlackMrkdwnTextObject {
text: string;
type: "mrkdwn";
verbatim?: boolean;
}

export type SlackTextObject = SlackMrkdwnTextObject | SlackPlainTextObject;

export interface SlackTextOptions {
emoji?: boolean;
verbatim?: boolean;
}

export interface SlackDateOptions {
link?: string;
}

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 TEXT_OBJECT_MAX_LENGTH = 3000;

export function escapeSlackText(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

export function unescapeSlackText(text: string): string {
return text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}

export function createSlackPlainText(
text: string,
options: SlackTextOptions = {}
): SlackPlainTextObject {
assertSlackTextObjectText(text);
return {
...(options.emoji === undefined ? {} : { emoji: options.emoji }),
text,
type: "plain_text",
};
}

export function createSlackMrkdwn(
text: string,
options: SlackTextOptions = {}
): SlackMrkdwnTextObject {
assertSlackTextObjectText(text);
return {
text,
type: "mrkdwn",
...(options.verbatim === undefined ? {} : { verbatim: options.verbatim }),
};
}

export function formatSlackUser(userId: string): string {
assertSlackId(userId, "userId");
return `<@${userId}>`;
}

export function formatSlackChannel(channelId: string): string {
assertSlackId(channelId, "channelId");
return `<#${channelId}>`;
}

export function formatSlackUserGroup(userGroupId: string): string {
assertSlackId(userGroupId, "userGroupId");
return `<!subteam^${userGroupId}>`;
}

export function formatSlackSpecialMention(
mention: "channel" | "everyone" | "here"
): string {
return `<!${mention}>`;
}

export function formatSlackLink(url: string, label?: string): string {
assertNoSlackControl(url, "url");
return label ? `<${url}|${escapeSlackText(label)}>` : `<${url}>`;
}

export function formatSlackDate(
timestamp: Date | number,
token: string,
fallback: string,
options: SlackDateOptions = {}
): string {
assertNoSlackDateControl(token, "token");
const seconds =
timestamp instanceof Date
? Math.floor(timestamp.getTime() / 1000)
: timestamp;
if (!Number.isInteger(seconds)) {
throw new TypeError("timestamp must be an integer unix timestamp or Date");
}
const link = options.link ? `^${assertSlackDateLink(options.link)}` : "";
return `<!date^${seconds}^${token}${link}|${escapeSlackText(fallback)}>`;
}

export function slackMrkdwnToMarkdown(mrkdwn: string): string {
let markdown = mrkdwn;
markdown = markdown.replace(/<@([A-Z0-9_]+)\|([^<>]+)>/g, "@$2");
markdown = markdown.replace(/<@([A-Z0-9_]+)>/g, "@$1");
markdown = markdown.replace(/<#[A-Z0-9_]+\|([^<>]+)>/g, "#$1");
markdown = markdown.replace(/<#([A-Z0-9_]+)>/g, "#$1");
markdown = markdown.replace(/<(https?:\/\/[^|<>]+)\|([^<>]+)>/g, "[$2]($1)");
markdown = markdown.replace(/<(https?:\/\/[^<>]+)>/g, "$1");
markdown = markdown.replace(/(?<![_*\\])\*([^*\n]+)\*(?![_*])/g, "**$1**");
markdown = markdown.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "~~$1~~");
return unescapeSlackText(markdown);
}

export function markdownBoldToSlackMrkdwn(markdown: string): string {
return markdown.replace(/\*\*(.+?)\*\*/g, "*$1*");
}

export function linkBareSlackMentions(text: string): string {
return text.replace(SLACK_USER_TOKEN_PATTERN, "<@$1>");
}

function assertSlackTextObjectText(text: string): void {
if (text.length < 1 || text.length > TEXT_OBJECT_MAX_LENGTH) {
throw new TypeError(
`text must be between 1 and ${TEXT_OBJECT_MAX_LENGTH} characters`
);
}
}

function assertSlackId(value: string, name: string): void {
if (!SLACK_ID_PATTERN.test(value)) {
throw new TypeError(`${name} must be a Slack ID`);
}
}

function assertNoSlackControl(value: string, name: string): void {
if (CONTROL_PATTERN.test(value)) {
throw new TypeError(`${name} cannot contain Slack control characters`);
}
}

function assertNoSlackDateControl(value: string, name: string): void {
if (DATE_CONTROL_PATTERN.test(value)) {
throw new TypeError(`${name} cannot contain Slack date control characters`);
}
}

function assertSlackDateLink(value: string): string {
assertNoSlackDateControl(value, "link");
return value;
}
1 change: 1 addition & 0 deletions packages/adapter-slack/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from "tsup";

export default defineConfig({
entry: {
format: "src/format/index.ts",
index: "src/index.ts",
webhook: "src/webhook/index.ts",
},
Expand Down