From 7749a0af330f0ff00cc1f2387395d27bebde0d7c Mon Sep 17 00:00:00 2001 From: dancer Date: Fri, 22 May 2026 01:49:35 +0100 Subject: [PATCH] feat(slack): add api primitives --- .changeset/green-foxes-find.md | 5 + packages/adapter-slack/package.json | 4 + .../adapter-slack/src/api/boundary.test.ts | 17 + packages/adapter-slack/src/api/index.test.ts | 316 ++++++++++++ packages/adapter-slack/src/api/index.ts | 458 ++++++++++++++++++ packages/adapter-slack/tsup.config.ts | 1 + 6 files changed, 801 insertions(+) create mode 100644 .changeset/green-foxes-find.md create mode 100644 packages/adapter-slack/src/api/boundary.test.ts create mode 100644 packages/adapter-slack/src/api/index.test.ts create mode 100644 packages/adapter-slack/src/api/index.ts diff --git a/.changeset/green-foxes-find.md b/.changeset/green-foxes-find.md new file mode 100644 index 00000000..e1697d5d --- /dev/null +++ b/.changeset/green-foxes-find.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +add lightweight Slack API primitives subpath diff --git a/packages/adapter-slack/package.json b/packages/adapter-slack/package.json index cd569546..fb1c31d2 100644 --- a/packages/adapter-slack/package.json +++ b/packages/adapter-slack/package.json @@ -21,6 +21,10 @@ "./format": { "types": "./dist/format.d.ts", "import": "./dist/format.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "import": "./dist/api.js" } }, "files": [ diff --git a/packages/adapter-slack/src/api/boundary.test.ts b/packages/adapter-slack/src/api/boundary.test.ts new file mode 100644 index 00000000..8e6fff46 --- /dev/null +++ b/packages/adapter-slack/src/api/boundary.test.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("api 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"'); + }); +}); diff --git a/packages/adapter-slack/src/api/index.test.ts b/packages/adapter-slack/src/api/index.test.ts new file mode 100644 index 00000000..77cffe04 --- /dev/null +++ b/packages/adapter-slack/src/api/index.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from "vitest"; +import { + callSlackApi, + deleteSlackMessage, + encodeSlackApiBody, + fetchSlackFile, + postSlackEphemeral, + postSlackMessage, + SlackApiError, + sendSlackResponseUrl, + updateSlackMessage, + uploadSlackFiles, +} from "./index"; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function textRequestBody( + _input: RequestInfo | URL, + init?: RequestInit +): string { + return String(init?.body ?? ""); +} + +describe("Slack api primitives", () => { + it("form-encodes Slack API bodies with JSON object values", () => { + const encoded = encodeSlackApiBody({ + blocks: [{ type: "section" }], + channel: "C123", + reply_broadcast: false, + text: "hello", + thread_ts: undefined, + }); + + expect(encoded.contentType).toBe("application/x-www-form-urlencoded"); + expect(new URLSearchParams(encoded.body).get("blocks")).toBe( + '[{"type":"section"}]' + ); + expect(new URLSearchParams(encoded.body).get("reply_broadcast")).toBe( + "false" + ); + expect(new URLSearchParams(encoded.body).has("thread_ts")).toBe(false); + }); + + it("calls Slack Web API with bearer token auth", async () => { + const request = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + await callSlackApi( + "chat.postMessage", + { channel: "C123", text: "hello" }, + { fetch: request, token: async () => "xoxb-token" } + ); + + expect(String(request.mock.calls[0][0])).toBe( + "https://slack.com/api/chat.postMessage" + ); + expect(request.mock.calls[0][1].headers.authorization).toBe( + "Bearer xoxb-token" + ); + expect( + new URLSearchParams(textRequestBody(...request.mock.calls[0])).get("text") + ).toBe("hello"); + }); + + it("supports custom API origins for tests and proxies", async () => { + const request = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + + await callSlackApi( + "chat.postMessage", + {}, + { + apiUrl: "https://proxy.example/slack/", + fetch: request, + token: "xoxb-token", + } + ); + + expect(String(request.mock.calls[0][0])).toBe( + "https://proxy.example/slack/chat.postMessage" + ); + }); + + it("throws for non-2xx Slack API HTTP responses", async () => { + const request = vi + .fn() + .mockResolvedValue( + jsonResponse({ error: "ratelimited", ok: false }, { status: 429 }) + ); + + await expect( + callSlackApi("chat.postMessage", {}, { fetch: request, token: "xoxb" }) + ).rejects.toMatchObject({ + method: "chat.postMessage", + name: "SlackApiError", + status: 429, + }); + }); + + it("posts messages and returns the Slack timestamp", async () => { + const request = vi + .fn() + .mockResolvedValue( + jsonResponse({ channel: "C123", ok: true, ts: "1.23" }) + ); + + const result = await postSlackMessage({ + channel: "C123", + fetch: request, + markdownText: "**hello**", + token: "xoxb", + unfurlLinks: false, + unfurlMedia: false, + }); + + const params = new URLSearchParams( + textRequestBody(...request.mock.calls[0]) + ); + expect(params.get("markdown_text")).toBe("**hello**"); + expect(params.get("text")).toBeNull(); + expect(params.get("blocks")).toBeNull(); + expect(params.get("unfurl_links")).toBe("false"); + expect(result).toEqual({ + channel: "C123", + id: "1.23", + raw: { channel: "C123", ok: true, ts: "1.23" }, + }); + }); + + it("rejects markdown_text conflicts locally", async () => { + await expect( + postSlackMessage({ + channel: "C123", + fetch: vi.fn(), + markdownText: "**hello**", + text: "hello", + token: "xoxb", + }) + ).rejects.toThrow(TypeError); + }); + + it("posts ephemeral messages", async () => { + const request = vi + .fn() + .mockResolvedValue( + jsonResponse({ channel: "C123", message_ts: "1.24", ok: true }) + ); + + const result = await postSlackEphemeral({ + channel: "C123", + fetch: request, + text: "hello", + token: "xoxb", + user: "U123", + }); + + const params = new URLSearchParams( + textRequestBody(...request.mock.calls[0]) + ); + expect(String(request.mock.calls[0][0])).toBe( + "https://slack.com/api/chat.postEphemeral" + ); + expect(params.get("user")).toBe("U123"); + expect(result.id).toBe("1.24"); + }); + + it("updates messages", async () => { + const request = vi + .fn() + .mockResolvedValue( + jsonResponse({ channel: "C123", ok: true, ts: "1.25" }) + ); + + const result = await updateSlackMessage({ + blocks: [{ type: "section" }], + channel: "C123", + fetch: request, + text: "fallback", + token: "xoxb", + ts: "1.23", + }); + + const params = new URLSearchParams( + textRequestBody(...request.mock.calls[0]) + ); + expect(String(request.mock.calls[0][0])).toBe( + "https://slack.com/api/chat.update" + ); + expect(params.get("ts")).toBe("1.23"); + expect(params.get("blocks")).toBe('[{"type":"section"}]'); + expect(result.id).toBe("1.25"); + }); + + it("deletes messages", async () => { + const request = vi + .fn() + .mockResolvedValue(jsonResponse({ ok: true, ts: "1.23" })); + + await deleteSlackMessage({ + channel: "C123", + fetch: request, + token: "xoxb", + ts: "1.23", + }); + + const params = new URLSearchParams( + textRequestBody(...request.mock.calls[0]) + ); + expect(String(request.mock.calls[0][0])).toBe( + "https://slack.com/api/chat.delete" + ); + expect(params.get("channel")).toBe("C123"); + expect(params.get("ts")).toBe("1.23"); + }); + + it("throws SlackApiError for ok false helper responses", async () => { + const request = vi + .fn() + .mockResolvedValue( + jsonResponse({ error: "channel_not_found", ok: false }) + ); + + await expect( + postSlackMessage({ + channel: "C123", + fetch: request, + text: "hello", + token: "xoxb", + }) + ).rejects.toBeInstanceOf(SlackApiError); + }); + + it("sends response_url JSON payloads", async () => { + const request = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + + await sendSlackResponseUrl( + "https://hooks.slack.com/actions/T/1/abc", + { + replaceOriginal: true, + text: "updated", + }, + { fetch: request } + ); + + expect(request.mock.calls[0][0]).toBe( + "https://hooks.slack.com/actions/T/1/abc" + ); + expect(JSON.parse(String(request.mock.calls[0][1].body))).toEqual({ + replace_original: true, + text: "updated", + }); + }); + + it("uploads files with Slack external upload flow", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + file_id: "F123", + ok: true, + upload_url: "https://files.slack.com/upload/v1/abc", + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce( + jsonResponse({ files: [{ id: "F123" }], ok: true }) + ); + + const result = await uploadSlackFiles( + [{ data: new Uint8Array([1, 2, 3]), filename: "report.txt" }], + { + channelId: "C123", + fetch: request, + initialComment: "here", + threadTs: "1.23", + token: "xoxb", + } + ); + + expect(String(request.mock.calls[0][0])).toBe( + "https://slack.com/api/files.getUploadURLExternal" + ); + expect( + new URLSearchParams(textRequestBody(...request.mock.calls[0])).get( + "length" + ) + ).toBe("3"); + expect(request.mock.calls[1][0]).toBe( + "https://files.slack.com/upload/v1/abc" + ); + expect(request.mock.calls[1][1].headers.authorization).toBe("Bearer xoxb"); + expect(String(request.mock.calls[2][0])).toBe( + "https://slack.com/api/files.completeUploadExternal" + ); + expect(result.fileIds).toEqual(["F123"]); + }); + + it("fetches private Slack file URLs with bearer auth", async () => { + const response = new Response("file", { status: 200 }); + const request = vi.fn().mockResolvedValue(response); + + const result = await fetchSlackFile({ + fetch: request, + token: "xoxb", + url: "https://files.slack.com/files-pri/T/F/report.txt", + }); + + expect(result).toBe(response); + expect(request.mock.calls[0][1].headers.authorization).toBe("Bearer xoxb"); + }); +}); diff --git a/packages/adapter-slack/src/api/index.ts b/packages/adapter-slack/src/api/index.ts new file mode 100644 index 00000000..e2cc7c00 --- /dev/null +++ b/packages/adapter-slack/src/api/index.ts @@ -0,0 +1,458 @@ +export type SlackBotToken = string | (() => Promise | string); + +export type SlackFetch = typeof fetch; + +export interface SlackApiResponse { + error?: string; + needed?: string; + ok: boolean; + provided?: string; + response_metadata?: { + messages?: string[]; + warnings?: string[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface SlackApiOptions { + apiUrl?: string; + fetch?: SlackFetch; + token: SlackBotToken; +} + +export interface SlackApiCallOptions extends SlackApiOptions { + contentType?: "form" | "json"; +} + +export interface SlackMessageOptions extends SlackApiOptions { + blocks?: unknown[]; + channel: string; + markdownText?: string; + metadata?: unknown; + replyBroadcast?: boolean; + text?: string; + threadTs?: string; + unfurlLinks?: boolean; + unfurlMedia?: boolean; +} + +export interface SlackEphemeralOptions extends SlackMessageOptions { + user: string; +} + +export interface SlackUpdateOptions extends SlackMessageOptions { + ts: string; +} + +export interface SlackDeleteOptions extends SlackApiOptions { + channel: string; + ts: string; +} + +export interface SlackPostedMessage { + channel?: string; + id: string; + raw: SlackApiResponse; +} + +export interface SlackResponseUrlPayload { + blocks?: unknown[]; + deleteOriginal?: boolean; + replaceOriginal?: boolean; + responseType?: "ephemeral" | "in_channel"; + text?: string; + threadTs?: string; +} + +export interface SlackResponseUrlOptions { + fetch?: SlackFetch; +} + +export interface SlackFileUpload { + altText?: string; + data: ArrayBuffer | Blob | Uint8Array; + filename: string; + snippetType?: string; + title?: string; +} + +export interface SlackUploadOptions extends SlackApiOptions { + channelId?: string; + initialComment?: string; + threadTs?: string; +} + +export interface SlackUploadResult { + fileIds: string[]; + raw: SlackApiResponse; +} + +export interface SlackFileFetchOptions extends SlackApiOptions { + url: string; +} + +export class SlackApiError extends Error { + method: string; + response?: SlackApiResponse; + status?: number; + + constructor( + message: string, + options: { method: string; response?: SlackApiResponse; status?: number } + ) { + super(message); + this.name = "SlackApiError"; + this.method = options.method; + this.response = options.response; + this.status = options.status; + } +} + +const DEFAULT_API_URL = "https://slack.com/api/"; + +export async function resolveSlackBotToken( + token: SlackBotToken +): Promise { + return typeof token === "function" ? await token() : token; +} + +export async function callSlackApi< + TResponse extends SlackApiResponse = SlackApiResponse, +>( + method: string, + body: Record, + options: SlackApiCallOptions +): Promise { + const token = await resolveSlackBotToken(options.token); + const encoded = encodeSlackApiBody(body, options.contentType ?? "form"); + const request = options.fetch ?? fetch; + const response = await request( + new URL(method, options.apiUrl ?? DEFAULT_API_URL), + { + body: encoded.body, + headers: { + authorization: `Bearer ${token}`, + "content-type": encoded.contentType, + }, + method: "POST", + } + ); + const payload = (await response.json()) as TResponse; + if (!response.ok) { + throw new SlackApiError( + `Slack ${method} returned HTTP ${response.status}`, + { + method, + response: payload, + status: response.status, + } + ); + } + return payload; +} + +export async function postSlackMessage( + options: SlackMessageOptions +): Promise { + const raw = await callSlackApi( + "chat.postMessage", + slackMessageBody(options), + options + ); + assertSlackOk("chat.postMessage", raw); + return { + channel: optionalString(raw.channel), + id: stringValue(raw.ts), + raw, + }; +} + +export async function postSlackEphemeral( + options: SlackEphemeralOptions +): Promise { + const raw = await callSlackApi( + "chat.postEphemeral", + { + ...slackMessageBody(options), + user: options.user, + }, + options + ); + assertSlackOk("chat.postEphemeral", raw); + return { + channel: optionalString(raw.channel), + id: stringValue(raw.message_ts), + raw, + }; +} + +export async function updateSlackMessage( + options: SlackUpdateOptions +): Promise { + const raw = await callSlackApi( + "chat.update", + { + ...slackMessageBody(options), + ts: options.ts, + }, + options + ); + assertSlackOk("chat.update", raw); + return { + channel: optionalString(raw.channel), + id: stringValue(raw.ts), + raw, + }; +} + +export async function deleteSlackMessage( + options: SlackDeleteOptions +): Promise { + const raw = await callSlackApi( + "chat.delete", + { + channel: options.channel, + ts: options.ts, + }, + options + ); + assertSlackOk("chat.delete", raw); + return raw; +} + +export async function sendSlackResponseUrl( + url: string, + payload: SlackResponseUrlPayload, + options: SlackResponseUrlOptions = {} +): Promise { + const request = options.fetch ?? fetch; + const response = await request(url, { + body: JSON.stringify(responseUrlBody(payload)), + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + if (!response.ok) { + throw new SlackApiError( + `Slack response_url returned HTTP ${response.status}`, + { + method: "response_url", + status: response.status, + } + ); + } +} + +export async function uploadSlackFiles( + files: readonly SlackFileUpload[], + options: SlackUploadOptions +): Promise { + if (files.length === 0) { + return { fileIds: [], raw: { ok: true } }; + } + const token = await resolveSlackBotToken(options.token); + const request = options.fetch ?? fetch; + const fileIds: string[] = []; + for (const file of files) { + const bytes = await readSlackFileBytes(file.data); + const upload = await callSlackApi( + "files.getUploadURLExternal", + { + alt_txt: file.altText, + filename: file.filename, + length: bytes.byteLength, + snippet_type: file.snippetType, + }, + options + ); + assertSlackOk("files.getUploadURLExternal", upload); + const uploadUrl = stringValue(upload.upload_url); + const fileId = stringValue(upload.file_id); + if (!(uploadUrl && fileId)) { + throw new SlackApiError( + "Slack files.getUploadURLExternal returned no upload URL", + { + method: "files.getUploadURLExternal", + response: upload, + } + ); + } + const response = await request(uploadUrl, { + body: bytes, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/octet-stream", + }, + method: "POST", + }); + if (!response.ok) { + throw new SlackApiError( + `Slack file upload returned HTTP ${response.status}`, + { + method: "files.upload", + status: response.status, + } + ); + } + fileIds.push(fileId); + } + const raw = await callSlackApi( + "files.completeUploadExternal", + { + channel_id: options.channelId, + files: files.map((file, index) => ({ + id: fileIds[index], + title: file.title ?? file.filename, + })), + initial_comment: options.initialComment, + thread_ts: options.threadTs, + }, + options + ); + assertSlackOk("files.completeUploadExternal", raw); + return { fileIds, raw }; +} + +export async function fetchSlackFile( + options: SlackFileFetchOptions +): Promise { + const token = await resolveSlackBotToken(options.token); + const request = options.fetch ?? fetch; + const response = await request(options.url, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new SlackApiError( + `Slack file fetch returned HTTP ${response.status}`, + { + method: "files.fetch", + status: response.status, + } + ); + } + return response; +} + +export function encodeSlackApiBody( + body: Record, + contentType: "form" | "json" = "form" +): { body: string; contentType: string } { + if (contentType === "json") { + return { + body: JSON.stringify(removeUndefined(body)), + contentType: "application/json", + }; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(body)) { + if (value === undefined || value === null) { + continue; + } + params.set(key, encodeSlackApiValue(value)); + } + return { + body: params.toString(), + contentType: "application/x-www-form-urlencoded", + }; +} + +export function assertSlackOk( + method: string, + response: SlackApiResponse +): void { + if (response.ok !== true) { + throw new SlackApiError( + `Slack ${method} failed: ${response.error ?? "unknown_error"}`, + { + method, + response, + } + ); + } +} + +function slackMessageBody( + options: SlackMessageOptions +): Record { + assertSlackMessageContent(options); + return { + blocks: options.blocks, + channel: options.channel, + markdown_text: options.markdownText, + metadata: options.metadata, + reply_broadcast: options.replyBroadcast, + text: options.text, + thread_ts: options.threadTs, + unfurl_links: options.unfurlLinks, + unfurl_media: options.unfurlMedia, + }; +} + +function responseUrlBody( + payload: SlackResponseUrlPayload +): Record { + return { + blocks: payload.blocks, + delete_original: payload.deleteOriginal, + replace_original: payload.replaceOriginal, + response_type: payload.responseType, + text: payload.text, + thread_ts: payload.threadTs, + }; +} + +function assertSlackMessageContent(options: SlackMessageOptions): void { + if ( + options.markdownText !== undefined && + (options.text !== undefined || options.blocks !== undefined) + ) { + throw new TypeError("markdownText cannot be used with text or blocks"); + } +} + +function encodeSlackApiValue(value: unknown): string { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return String(value); + } + return JSON.stringify(value); +} + +function removeUndefined( + value: Record +): Record { + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) { + output[key] = item; + } + } + return output; +} + +async function readSlackFileBytes( + data: ArrayBuffer | Blob | Uint8Array +): Promise { + if (data instanceof Uint8Array) { + return data; + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + return new Uint8Array(await data.arrayBuffer()); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index f9d3f8f3..df8e1847 100644 --- a/packages/adapter-slack/tsup.config.ts +++ b/packages/adapter-slack/tsup.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { + api: "src/api/index.ts", format: "src/format/index.ts", index: "src/index.ts", webhook: "src/webhook/index.ts",