import { beforeEach, describe, expect, it, mock } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import SlackChannel, { DEFAULT_FORWARD_TIMEOUT_MS, parseForwardTimeoutMs } from "./index.ts"; import { checkPermissions, loadPermissionConfig, parseIdList } from "./permissions.ts"; import { ConversationQueue } from './runtime.ts'; import type { PermissionConfig, PermissionResult, UserInfo } from "./types.ts"; // ── Helpers ───────────────────────────────────────────────────────────────── function emptyPermissions(): PermissionConfig { return { allowedChannels: new Set(), allowedUsers: new Set(), blockedUsers: new Set(), }; } function testUser(overrides: Partial = {}): UserInfo { return { userId: "U12345", teamId: "T12345", channelId: "C12345", username: "testuser", ...overrides, }; } function withSecretFile(envKey: string, value: string, run: () => void): void { const original = Bun.env[envKey]; const dir = mkdtempSync(join(tmpdir(), "openpalm-channel-test-")); const path = join(dir, envKey.toLowerCase()); writeFileSync(path, `${value}\n`); try { Bun.env[envKey] = path; run(); } finally { if (original === undefined) delete Bun.env[envKey]; else Bun.env[envKey] = original; rmSync(dir, { recursive: true, force: true }); } } function deferred(): { promise: Promise; resolve: () => void } { let resolve = () => {}; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } type MockClient = { chat: { postMessage: ReturnType; update: ReturnType; }; conversations: { open: ReturnType; }; users: { info: ReturnType; }; views: { open: ReturnType; publish: ReturnType; }; }; function createMockClient(): MockClient { return { chat: { postMessage: mock(async () => ({ ts: "1234567890.123456" })), update: mock(async () => ({})), }, conversations: { open: mock(async () => ({ channel: { id: "D123" } })), }, users: { info: mock(async ({ user }: { user: string }) => ({ user: { name: user } })), }, views: { open: mock(async () => ({})), publish: mock(async () => ({})), }, }; } type MockSay = ReturnType; function createMockSay(): MockSay { return mock(async () => ({})); } beforeEach(() => { delete Bun.env.SLACK_FORWARD_TIMEOUT_MS; }); // ── Forward timeout parsing ────────────────────────────────────────────────── describe("parseForwardTimeoutMs", () => { it("uses default when value is missing", () => { expect(parseForwardTimeoutMs(undefined)).toBe(DEFAULT_FORWARD_TIMEOUT_MS); }); it("uses default when value is invalid, zero, or negative", () => { expect(parseForwardTimeoutMs("nope")).toBe(DEFAULT_FORWARD_TIMEOUT_MS); expect(parseForwardTimeoutMs("0")).toBe(DEFAULT_FORWARD_TIMEOUT_MS); expect(parseForwardTimeoutMs("-1")).toBe(DEFAULT_FORWARD_TIMEOUT_MS); }); it("uses the configured positive value", () => { expect(parseForwardTimeoutMs("12345")).toBe(12345); }); }); // ── parseIdList ───────────────────────────────────────────────────────────── describe("parseIdList", () => { it("returns empty set for undefined", () => { expect(parseIdList(undefined).size).toBe(0); }); it("returns empty set for empty string", () => { expect(parseIdList("").size).toBe(0); }); it("returns empty set for whitespace-only", () => { expect(parseIdList(" ").size).toBe(0); }); it("splits comma-separated values", () => { const result = parseIdList("a,b,c"); expect(result.size).toBe(3); expect(result.has("a")).toBe(true); expect(result.has("b")).toBe(true); expect(result.has("c")).toBe(true); }); it("trims whitespace", () => { const result = parseIdList(" a , b , c "); expect(result.has("a")).toBe(true); expect(result.has("b")).toBe(true); expect(result.has("c")).toBe(true); }); it("filters empty entries", () => { const result = parseIdList("a,,b,,,c"); expect(result.size).toBe(3); }); it("handles single value", () => { const result = parseIdList("U12345"); expect(result.size).toBe(1); expect(result.has("U12345")).toBe(true); }); it("deduplicates repeated IDs", () => { const result = parseIdList("id1,id1,id1"); expect(result.size).toBe(1); }); it("filters entries from trailing commas", () => { const result = parseIdList("id1,,id2,"); expect(result.size).toBe(2); expect(result.has("id1")).toBe(true); expect(result.has("id2")).toBe(true); }); }); // ── checkPermissions ──────────────────────────────────────────────────────── describe("checkPermissions", () => { it("allows when all lists are empty", () => { const result = checkPermissions(emptyPermissions(), testUser()); expect(result.allowed).toBe(true); expect(result.reason).toBeUndefined(); }); it("blocks a blocked user", () => { const config = { ...emptyPermissions(), blockedUsers: new Set(["U12345"]) }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(false); expect(result.reason).toBe("user_blocked"); }); it("blocked takes precedence over allowed", () => { const config: PermissionConfig = { allowedChannels: new Set(), allowedUsers: new Set(["U12345"]), blockedUsers: new Set(["U12345"]), }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(false); expect(result.reason).toBe("user_blocked"); }); it("allows user in allowedUsers", () => { const config = { ...emptyPermissions(), allowedUsers: new Set(["U12345"]) }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(true); }); it("denies user not in allowedUsers", () => { const config = { ...emptyPermissions(), allowedUsers: new Set(["U99999"]) }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(false); expect(result.reason).toBe("user_not_allowed"); }); it("allows user in allowed channel", () => { const config = { ...emptyPermissions(), allowedChannels: new Set(["C12345"]) }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(true); }); it("denies user not in allowed channel", () => { const config = { ...emptyPermissions(), allowedChannels: new Set(["C99999"]) }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(false); expect(result.reason).toBe("channel_not_allowed"); }); it("allows when no channel provided and channels unrestricted", () => { const result = checkPermissions(emptyPermissions(), testUser({ channelId: "" })); expect(result.allowed).toBe(true); }); it("denies when channel required but empty", () => { const config = { ...emptyPermissions(), allowedChannels: new Set(["C12345"]) }; const result = checkPermissions(config, testUser({ channelId: "" })); expect(result.allowed).toBe(false); }); it("denies user with empty userId when users are restricted", () => { const config = { ...emptyPermissions(), allowedUsers: new Set(["U12345"]) }; const result = checkPermissions(config, testUser({ userId: "" })); expect(result.allowed).toBe(false); expect(result.reason).toBe("user_not_allowed"); }); it("checks both user and channel restrictions", () => { const config: PermissionConfig = { allowedChannels: new Set(["C12345"]), allowedUsers: new Set(["U12345"]), blockedUsers: new Set(), }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(true); }); it("fails if user matches but channel does not", () => { const config: PermissionConfig = { allowedChannels: new Set(["C99999"]), allowedUsers: new Set(["U12345"]), blockedUsers: new Set(), }; const result = checkPermissions(config, testUser()); expect(result.allowed).toBe(false); expect(result.reason).toBe("channel_not_allowed"); }); }); // ── loadPermissionConfig ──────────────────────────────────────────────────── describe("loadPermissionConfig", () => { it("loads from env vars", () => { const config = loadPermissionConfig({ SLACK_ALLOWED_CHANNELS: "C1,C2", SLACK_ALLOWED_USERS: "U1", SLACK_BLOCKED_USERS: "U99", }); expect(config.allowedChannels.size).toBe(2); expect(config.allowedUsers.size).toBe(1); expect(config.blockedUsers.size).toBe(1); }); it("returns empty sets when env vars missing", () => { const config = loadPermissionConfig({}); expect(config.allowedChannels.size).toBe(0); expect(config.allowedUsers.size).toBe(0); expect(config.blockedUsers.size).toBe(0); }); it("handles whitespace in env values", () => { const config = loadPermissionConfig({ SLACK_ALLOWED_CHANNELS: " C1 , C2 ", }); expect(config.allowedChannels.has("C1")).toBe(true); expect(config.allowedChannels.has("C2")).toBe(true); }); }); // ── ConversationQueue ─────────────────────────────────────────────────────── describe("ConversationQueue", () => { it("runs task immediately when not processing", async () => { const queue = new ConversationQueue(); let ran = false; const result = await queue.runOrQueue("key1", { run: async () => { ran = true; }, }); expect(result).toBe("started"); expect(ran).toBe(true); }); it("queues task when already processing", async () => { const queue = new ConversationQueue(); const order: number[] = []; const blocker = deferred(); const firstPromise = queue.runOrQueue("key1", { run: async () => { order.push(1); await blocker.promise; }, }); const secondResult = await queue.runOrQueue("key1", { run: async () => { order.push(2); }, }); expect(secondResult).toBe("queued"); blocker.resolve(); await firstPromise; await new Promise((r) => setTimeout(r, 50)); expect(order).toEqual([1, 2]); }); it("calls onQueued when task is queued", async () => { const queue = new ConversationQueue(); let queuedCalled = false; const blocker = deferred(); queue.runOrQueue("key1", { run: async () => { await blocker.promise; }, }); await queue.runOrQueue("key1", { onQueued: async () => { queuedCalled = true; }, run: async () => {}, }); expect(queuedCalled).toBe(true); blocker.resolve(); }); it("clear drops queued tasks", async () => { const queue = new ConversationQueue(); const blocker = deferred(); queue.runOrQueue("key1", { run: async () => { await blocker.promise; }, }); queue.runOrQueue("key1", { run: async () => {} }); queue.runOrQueue("key1", { run: async () => {} }); const dropped = queue.clear("key1"); expect(dropped).toBe(2); blocker.resolve(); }); it("isProcessing returns correct state", async () => { const queue = new ConversationQueue(); expect(queue.isProcessing("key1")).toBe(false); const blocker = deferred(); queue.runOrQueue("key1", { run: async () => { await blocker.promise; }, }); expect(queue.isProcessing("key1")).toBe(true); blocker.resolve(); }); it("queuedCount tracks pending tasks", async () => { const queue = new ConversationQueue(); const blocker = deferred(); queue.runOrQueue("key1", { run: async () => { await blocker.promise; }, }); expect(queue.queuedCount("key1")).toBe(0); queue.runOrQueue("key1", { run: async () => {} }); expect(queue.queuedCount("key1")).toBe(1); queue.runOrQueue("key1", { run: async () => {} }); expect(queue.queuedCount("key1")).toBe(2); blocker.resolve(); }); it("cleans up state after all tasks complete", async () => { const queue = new ConversationQueue(); await queue.runOrQueue("key1", { run: async () => {} }); expect(queue.isProcessing("key1")).toBe(false); expect(queue.queuedCount("key1")).toBe(0); }); it("clear returns 0 for unknown session key", () => { const queue = new ConversationQueue(); expect(queue.clear("nonexistent")).toBe(0); }); it("runs queued work sequentially (FIFO)", async () => { const queue = new ConversationQueue(); const blocker = deferred(); const events: string[] = []; const first = queue.runOrQueue("s1", { run: async () => { events.push("first:start"); await blocker.promise; events.push("first:end"); }, }); const second = queue.runOrQueue("s1", { onQueued: async () => { events.push("second:queued"); }, run: async () => { events.push("second:run"); }, }); expect(await second).toBe("queued"); expect(queue.queuedCount("s1")).toBe(1); blocker.resolve(); expect(await first).toBe("started"); await Bun.sleep(0); expect(events).toEqual(["first:start", "second:queued", "first:end", "second:run"]); expect(queue.isProcessing("s1")).toBe(false); }); it("drops queued work when cleared", async () => { const queue = new ConversationQueue(); const blocker = deferred(); const events: string[] = []; const first = queue.runOrQueue("s1", { run: async () => { events.push("first:start"); await blocker.promise; events.push("first:end"); }, }); await queue.runOrQueue("s1", { run: async () => { events.push("second:run"); }, }); expect(queue.clear("s1")).toBe(1); blocker.resolve(); await first; await Bun.sleep(0); expect(events).toEqual(["first:start", "first:end"]); expect(queue.queuedCount("s1")).toBe(0); }); }); // ── SlackChannel class ────────────────────────────────────────────────────── describe("SlackChannel", () => { it("has correct name", () => { const channel = new SlackChannel(); expect(channel.name).toBe("slack"); }); it("handleRequest returns null (Socket Mode, no HTTP inbound)", () => { const channel = new SlackChannel(); const req = new Request("http://localhost/test", { method: "POST" }); return channel.handleRequest(req).then((result) => { expect(result).toBeNull(); }); }); it("health endpoint returns correct service name", async () => { const channel = new SlackChannel(); Object.defineProperty(channel, "secret", { value: "test-secret" }); const handler = channel.createFetch(); const resp = await handler(new Request("http://localhost/health")); expect(resp.status).toBe(200); const body = (await resp.json()) as Record; expect(body.ok).toBe(true); expect(body.service).toBe("channel-slack"); }); it("health endpoint responds to any host header", async () => { const channel = new SlackChannel(); Object.defineProperty(channel, "secret", { value: "test-secret" }); const handler = channel.createFetch(); const resp = await handler(new Request("http://127.0.0.1:8185/health")); expect(resp.status).toBe(200); }); it("returns 404 for non-POST requests", async () => { const channel = new SlackChannel(); Object.defineProperty(channel, "secret", { value: "test-secret" }); const handler = channel.createFetch(); const resp = await handler(new Request("http://localhost/message", { method: "GET" })); expect(resp.status).toBe(404); }); it("returns 404 for unknown paths", async () => { const channel = new SlackChannel(); Object.defineProperty(channel, "secret", { value: "test-secret" }); const handler = channel.createFetch(); const resp = await handler(new Request("http://localhost/nope")); expect(resp.status).toBe(404); }); it("botToken reads from SLACK_BOT_TOKEN_FILE", () => { withSecretFile("SLACK_BOT_TOKEN_FILE", "slack-bot-token", () => { const channel = new SlackChannel(); expect(channel.botToken).toBe("slack-bot-token"); }); }); it("appToken reads from SLACK_APP_TOKEN_FILE", () => { withSecretFile("SLACK_APP_TOKEN_FILE", "slack-app-token", () => { const channel = new SlackChannel(); expect(channel.appToken).toBe("slack-app-token"); }); }); it("inherits port from env or defaults to 8080", () => { const channel = new SlackChannel(); expect(typeof channel.port).toBe("number"); }); it("inherits guardianUrl from env or defaults", () => { const channel = new SlackChannel(); expect(typeof channel.guardianUrl).toBe("string"); expect(channel.guardianUrl).toContain("guardian"); }); it("secret resolves from PRINCIPAL_SECRET_FILE", () => { withSecretFile("PRINCIPAL_SECRET_FILE", "channel-secret", () => { const channel = new SlackChannel(); expect(channel.secret).toBe("channel-secret"); }); }); }); // ── Message handling behavior ─────────────────────────────────────────────── describe("DM message handling", () => { it("ignores bot messages (bot_id present)", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "hi" }), { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im", bot_id: "B123" }, say, client, ); expect(forward).not.toHaveBeenCalled(); expect(say).not.toHaveBeenCalled(); }); it("ignores messages with subtype", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im", subtype: "message_changed" }, say, client, ); expect(forward).not.toHaveBeenCalled(); }); it("ignores empty text", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: " ", ts: "1.1", channel_type: "im" }, say, client, ); expect(forward).not.toHaveBeenCalled(); }); it("ignores own messages (bot self-reply guard)", async () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "BSELF" }); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "BSELF", channel: "D123", text: "echo", ts: "1.1", channel_type: "im" }, say, client, ); expect(forward).not.toHaveBeenCalled(); expect(say).not.toHaveBeenCalled(); }); it("ignores non-DM messages (channel_type !== 'im')", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "C123", text: "hello", ts: "1.1", channel_type: "channel" }, say, client, ); expect(forward).not.toHaveBeenCalled(); }); it("forwards DM to guardian and replies in thread", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "Hi there!" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: "hello bot", ts: "1.1", channel_type: "im", team: "T1" }, say, client, ); expect(forward).toHaveBeenCalledTimes(1); expect(forward.mock.calls[0]?.[0]).toMatchObject({ userId: "slack:U123", text: "hello bot", }); // Thinking message posted, then updated with response expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({ channel: "D123", text: ":hourglass: Processing your request...", thread_ts: "1.1", }); expect(client.chat.update).toHaveBeenCalledWith({ channel: "D123", ts: "1234567890.123456", text: "Hi there!", }); }); it("uses thread_ts for session key when in a DM thread", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "reply" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: "follow up", ts: "2.2", thread_ts: "1.1", channel_type: "im", team: "T1", }, say, client, ); expect(forward.mock.calls[0]?.[0].metadata).toMatchObject({ sessionKey: "slack:thread:D123:1.1", }); }); it("denies blocked user in DM", async () => { const channel = new SlackChannel(); Object.assign(channel, { permissions: { allowedChannels: new Set(), allowedUsers: new Set(), blockedUsers: new Set(["U123"]), }, }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onMessage: (event: Record, say: MockSay, client: MockClient) => Promise; }).onMessage( { user: "U123", channel: "D123", text: "hello", ts: "1.1", channel_type: "im" }, say, client, ); expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot.", thread_ts: "1.1", }); expect(client.chat.postMessage).not.toHaveBeenCalled(); }); }); // ── App mention handling ──────────────────────────────────────────────────── describe("app mention handling", () => { it("responds to app_mention and replies in thread", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "mentioned!" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAppMention: (event: Record, say: MockSay, client: MockClient) => Promise; }).onAppMention( { user: "U123", channel: "C456", text: "hey bot help me", ts: "1.1", team: "T1" }, say, client, ); expect(forward).toHaveBeenCalledTimes(1); // Thinking message posted, then updated with response expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({ channel: "C456", text: ":hourglass: Processing your request...", thread_ts: "1.1", }); expect(client.chat.update).toHaveBeenCalledWith({ channel: "C456", ts: "1234567890.123456", text: "mentioned!", }); }); it("ignores empty text in app_mention", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAppMention: (event: Record, say: MockSay, client: MockClient) => Promise; }).onAppMention( { user: "U123", channel: "C456", text: "", ts: "1.1" }, say, client, ); expect(forward).not.toHaveBeenCalled(); }); it("strips bot mention from text", async () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "B999" }); const forward = mock(async () => new Response(JSON.stringify({ answer: "ok" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAppMention: (event: Record, say: MockSay, client: MockClient) => Promise; }).onAppMention( { user: "U123", channel: "C456", text: "<@B999> help me please", ts: "1.1", team: "T1" }, say, client, ); expect(forward.mock.calls[0]?.[0].text).toBe("help me please"); }); it("replies 'Please provide a message' when mention-only (no text after strip)", async () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "B999" }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAppMention: (event: Record, say: MockSay, client: MockClient) => Promise; }).onAppMention( { user: "U123", channel: "C456", text: "<@B999>", ts: "1.1", team: "T1" }, say, client, ); expect(say).toHaveBeenCalledWith({ text: "Please provide a message.", thread_ts: "1.1", }); }); it("uses existing thread_ts for threaded mentions", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "threaded!" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAppMention: (event: Record, say: MockSay, client: MockClient) => Promise; }).onAppMention( { user: "U123", channel: "C456", text: "question", ts: "2.2", thread_ts: "1.1", team: "T1" }, say, client, ); expect(forward.mock.calls[0]?.[0].metadata).toMatchObject({ sessionKey: "slack:thread:C456:1.1", }); // Thinking message posted in thread, then updated with response expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({ channel: "C456", text: ":hourglass: Processing your request...", thread_ts: "1.1", }); expect(client.chat.update).toHaveBeenCalledWith({ channel: "C456", ts: "1234567890.123456", text: "threaded!", }); }); }); // ── Slash command: /clear ─────────────────────────────────────────────────── describe("/clear command", () => { it("forwards clearSession request with session metadata", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); await (channel as unknown as { onClearCommand: (cmd: Record, say: MockSay) => Promise; }).onClearCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(forward).toHaveBeenCalledTimes(1); expect(forward.mock.calls[0]?.[0]).toMatchObject({ userId: "slack:U123", text: "clear session", metadata: { command: "clear", channelId: "C456", teamId: "T1", username: "tester", clearSession: true, }, }); expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS); expect(say).toHaveBeenCalledWith({ text: "Conversation cleared." }); }); it("reports dropped queued follow-ups", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); Object.assign(channel, { forward }); // Pre-populate the queue const queue = new ConversationQueue(); const blocker = deferred(); queue.runOrQueue("slack:channel:C456:user:U123", { run: async () => { await blocker.promise; } }); queue.runOrQueue("slack:channel:C456:user:U123", { run: async () => {} }); Object.assign(channel, { conversationQueue: queue }); const say = createMockSay(); await (channel as unknown as { onClearCommand: (cmd: Record, say: MockSay) => Promise; }).onClearCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledWith({ text: "Conversation cleared. Dropped queued follow-ups." }); blocker.resolve(); }); it("handles guardian error gracefully", async () => { const channel = new SlackChannel(); const forward = mock(async () => { throw new Error("network failure"); }); Object.assign(channel, { forward }); const say = createMockSay(); await (channel as unknown as { onClearCommand: (cmd: Record, say: MockSay) => Promise; }).onClearCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledWith({ text: "Could not clear this conversation right now." }); }); it("denies blocked user", async () => { const channel = new SlackChannel(); Object.assign(channel, { permissions: { allowedChannels: new Set(), allowedUsers: new Set(), blockedUsers: new Set(["U123"]), }, }); const forward = mock(async () => new Response("{}", { status: 200 })); Object.assign(channel, { forward }); const say = createMockSay(); await (channel as unknown as { onClearCommand: (cmd: Record, say: MockSay) => Promise; }).onClearCommand( { user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." }); expect(forward).not.toHaveBeenCalled(); }); it("handles non-ok guardian response", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 500 })); Object.assign(channel, { forward }); const say = createMockSay(); await (channel as unknown as { onClearCommand: (cmd: Record, say: MockSay) => Promise; }).onClearCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledWith({ text: "Could not clear this conversation right now." }); }); }); // ── Slash command: /ask ───────────────────────────────────────────────────── describe("/ask command", () => { it("replies with usage when text is empty", async () => { const channel = new SlackChannel(); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAskCommand: (cmd: Record, say: MockSay, client: MockClient) => Promise; }).onAskCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, client, ); expect(say).toHaveBeenCalledWith({ text: "Usage: `/ask `" }); }); it("posts thinking message, forwards to guardian, updates with answer", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "Here is my answer" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAskCommand: (cmd: Record, say: MockSay, client: MockClient) => Promise; }).onAskCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "what is AI?" }, say, client, ); // Should post thinking message expect(client.chat.postMessage).toHaveBeenCalledWith({ channel: "C456", text: `:hourglass: Processing your request...`, }); // Should update thinking message with answer expect(client.chat.update).toHaveBeenCalledWith({ channel: "C456", ts: "1234567890.123456", text: "Here is my answer", }); // Should have forwarded to guardian expect(forward).toHaveBeenCalledTimes(1); expect(forward.mock.calls[0]?.[0]).toMatchObject({ userId: "slack:U123", text: "what is AI?", metadata: { command: "ask", teamId: "T1", username: "tester", }, }); expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS); }); it("uses configured SLACK_FORWARD_TIMEOUT_MS for guardian forwarding", async () => { Bun.env.SLACK_FORWARD_TIMEOUT_MS = "4321"; const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "Here is my answer" }), { status: 200 }), ); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAskCommand: (cmd: Record, say: MockSay, client: MockClient) => Promise; }).onAskCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "what is AI?" }, say, client, ); expect(forward.mock.calls[0]?.[2]).toBe(4321); }); it("updates thinking message with error on guardian failure", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response("{}", { status: 500 })); Object.assign(channel, { forward }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAskCommand: (cmd: Record, say: MockSay, client: MockClient) => Promise; }).onAskCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "test" }, say, client, ); expect(client.chat.update).toHaveBeenCalledWith({ channel: "C456", ts: "1234567890.123456", text: "Error: Guardian returned status 500", }); }); it("denies blocked user", async () => { const channel = new SlackChannel(); Object.assign(channel, { permissions: { allowedChannels: new Set(), allowedUsers: new Set(), blockedUsers: new Set(["U123"]), }, }); const say = createMockSay(); const client = createMockClient(); await (channel as unknown as { onAskCommand: (cmd: Record, say: MockSay, client: MockClient) => Promise; }).onAskCommand( { user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "hello" }, say, client, ); expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." }); expect(client.chat.postMessage).not.toHaveBeenCalled(); }); }); // ── Slash command: /help ──────────────────────────────────────────────────── describe("/help command", () => { it("denies blocked user", async () => { const channel = new SlackChannel(); Object.assign(channel, { permissions: { allowedChannels: new Set(), allowedUsers: new Set(), blockedUsers: new Set(["U123"]), }, }); const say = createMockSay(); await (channel as unknown as { onHelpCommand: (cmd: Record, say: MockSay) => Promise; }).onHelpCommand( { user_id: "U123", user_name: "blocked", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledWith({ text: "You do not have permission to use this bot." }); }); it("lists available commands", async () => { const channel = new SlackChannel(); const say = createMockSay(); await (channel as unknown as { onHelpCommand: (cmd: Record, say: MockSay) => Promise; }).onHelpCommand( { user_id: "U123", user_name: "tester", team_id: "T1", channel_id: "C456", text: "" }, say, ); expect(say).toHaveBeenCalledTimes(1); const text = say.mock.calls[0]?.[0]?.text as string; expect(text).toContain("/ask"); expect(text).toContain("/clear"); expect(text).toContain("/help"); expect(text).toContain("mention me"); expect(text).toContain("DM"); }); }); // ── Shortcuts, modal submissions, and App Home ───────────────────────────── describe("shortcut and modal handlers", () => { it("opens Ask OpenPalm modal from global shortcut", async () => { const channel = new SlackChannel(); const client = createMockClient(); await (channel as unknown as { onGlobalShortcut: (shortcut: Record, client: MockClient) => Promise; }).onGlobalShortcut( { trigger_id: "trigger-1", user: { id: "U123" }, team: { id: "T1" }, }, client, ); expect(client.views.open).toHaveBeenCalledTimes(1); const args = client.views.open.mock.calls[0]?.[0]; expect(args.trigger_id).toBe("trigger-1"); expect(args.view.callback_id).toBe("ask_openpalm_modal"); }); it("opens prefilled modal from message shortcut", async () => { const channel = new SlackChannel(); const client = createMockClient(); await (channel as unknown as { onMessageShortcut: (shortcut: Record, client: MockClient) => Promise; }).onMessageShortcut( { trigger_id: "trigger-2", user: { id: "U123" }, team: { id: "T1" }, channel: { id: "C456" }, message: { ts: "1710000000.000001", text: "Please summarize this" }, }, client, ); const args = client.views.open.mock.calls[0]?.[0]; expect(args.view.blocks[0].element.initial_value).toContain("Please summarize this"); expect(args.view.private_metadata).toContain("message-shortcut"); expect(args.view.private_metadata).toContain("C456"); }); it("handles modal submission from message shortcut using thread session", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "modal answer" }), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { onAskModalSubmission: ( body: Record, view: Record, client: MockClient, ) => Promise; }).onAskModalSubmission( { user: { id: "U123", username: "tester" }, team: { id: "T1" }, }, { private_metadata: JSON.stringify({ source: "message-shortcut", channelId: "C456", threadTs: "1710000000.000001", teamId: "T1", }), state: { values: { ask_openpalm_prompt_block: { ask_openpalm_prompt_action: { value: "use this context", }, }, }, }, }, client, ); expect(forward).toHaveBeenCalledTimes(1); expect(forward.mock.calls[0]?.[0]).toMatchObject({ userId: "slack:U123", text: "use this context", }); expect(forward.mock.calls[0]?.[0].metadata.sessionKey).toBe("slack:thread:C456:1710000000.000001"); expect(client.chat.postMessage.mock.calls[0]?.[0]).toMatchObject({ channel: "C456", thread_ts: "1710000000.000001", }); }); it("handles modal submission from global shortcut via DM channel", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "dm modal answer" }), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { onAskModalSubmission: ( body: Record, view: Record, client: MockClient, ) => Promise; }).onAskModalSubmission( { user: { id: "U999", username: "tester" }, team: { id: "T1" }, }, { private_metadata: JSON.stringify({ source: "global-shortcut", teamId: "T1" }), state: { values: { ask_openpalm_prompt_block: { ask_openpalm_prompt_action: { value: "question from modal", }, }, }, }, }, client, ); expect(client.conversations.open).toHaveBeenCalledWith({ users: "U999" }); expect(forward).toHaveBeenCalledTimes(1); expect(forward.mock.calls[0]?.[0].metadata.sessionKey).toBe("slack:dm:U999"); }); }); describe("app home", () => { it("publishes Home tab content on app_home_opened", async () => { const channel = new SlackChannel(); const client = createMockClient(); await (channel as unknown as { onAppHomeOpened: (event: Record, client: MockClient) => Promise; }).onAppHomeOpened( { user: "U123" }, client, ); expect(client.views.publish).toHaveBeenCalledTimes(1); const payload = client.views.publish.mock.calls[0]?.[0]; expect(payload.user_id).toBe("U123"); expect(payload.view.type).toBe("home"); }); }); // ── Conversation runner ───────────────────────────────────────────────────── describe("runConversation", () => { it("posts thinking message and updates it with response", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "done" }), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { runConversation: ( client: MockClient, channel: string, threadTs: string, userInfo: UserInfo, text: string, sessionKey: string, ) => Promise; }).runConversation(client, "C123", "1.1", testUser(), "hello", "key1"); // First call: thinking message expect(client.chat.postMessage.mock.calls[0][0]).toMatchObject({ channel: "C123", text: ":hourglass: Processing your request...", thread_ts: "1.1", }); // Thinking message updated with response expect(client.chat.update).toHaveBeenCalledWith({ channel: "C123", ts: "1234567890.123456", text: "done", }); expect(forward.mock.calls[0]?.[2]).toBe(DEFAULT_FORWARD_TIMEOUT_MS); }); it("updates thinking message with error on failure", async () => { const channel = new SlackChannel(); const forward = mock(async () => { throw new Error("timeout"); }); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { runConversation: ( client: MockClient, channel: string, threadTs: string, userInfo: UserInfo, text: string, sessionKey: string, ) => Promise; }).runConversation(client, "C123", "1.1", testUser(), "hello", "key1"); expect(client.chat.update).toHaveBeenCalledWith({ channel: "C123", ts: "1234567890.123456", text: "Error: timeout", }); }); it("continues even when thinking message fails to post", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({ answer: "still works" }), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); // First postMessage (thinking) fails, second (response) succeeds let callCount = 0; client.chat.postMessage = mock(async (args: Record) => { callCount++; if (callCount === 1) throw new Error("no permission"); return { ts: "1234567890.123456" }; }); await (channel as unknown as { runConversation: ( client: MockClient, channel: string, threadTs: string, userInfo: UserInfo, text: string, sessionKey: string, ) => Promise; }).runConversation(client, "C123", "1.1", testUser(), "hello", "key1"); // Should fall back to posting response as new message expect(client.chat.postMessage.mock.calls[1][0]).toMatchObject({ channel: "C123", text: "still works", thread_ts: "1.1", }); // Should NOT try to update a message that was never posted expect(client.chat.update).not.toHaveBeenCalled(); }); it("splits long responses into multiple messages", async () => { const channel = new SlackChannel(); const longAnswer = "x".repeat(5000); const forward = mock(async () => new Response(JSON.stringify({ answer: longAnswer }), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { runConversation: ( client: MockClient, channel: string, threadTs: string, userInfo: UserInfo, text: string, sessionKey: string, ) => Promise; }).runConversation(client, "C123", "1.1", testUser(), "hello", "key1"); // First call is thinking message, then update replaces it with first chunk, // then additional chunks posted as new messages const postCalls = client.chat.postMessage.mock.calls; // At least thinking message + follow-up chunks expect(postCalls.length).toBeGreaterThan(1); // Follow-up chunks should be in the thread for (let i = 1; i < postCalls.length; i++) { expect(postCalls[i][0].thread_ts).toBe("1.1"); } }); it("returns 'No response received.' when guardian returns no answer", async () => { const channel = new SlackChannel(); const forward = mock(async () => new Response(JSON.stringify({}), { status: 200 }), ); Object.assign(channel, { forward }); const client = createMockClient(); await (channel as unknown as { runConversation: ( client: MockClient, channel: string, threadTs: string, userInfo: UserInfo, text: string, sessionKey: string, ) => Promise; }).runConversation(client, "C123", "1.1", testUser(), "hello", "key1"); expect(client.chat.update).toHaveBeenCalledWith({ channel: "C123", ts: "1234567890.123456", text: "No response received.", }); }); }); // ── Utility: stripMention ─────────────────────────────────────────────────── describe("stripMention", () => { it("strips bot mention from text", () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "B999" }); const result = (channel as unknown as { stripMention: (text: string) => string; }).stripMention("<@B999> help me"); expect(result).toBe("help me"); }); it("strips multiple mentions", () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "B999" }); const result = (channel as unknown as { stripMention: (text: string) => string; }).stripMention("<@B999> do this <@B999>"); expect(result).toBe("do this"); }); it("returns original text when no botUserId", () => { const channel = new SlackChannel(); const result = (channel as unknown as { stripMention: (text: string) => string; }).stripMention("<@B999> help"); expect(result).toBe("<@B999> help"); }); it("returns original text when no mention present", () => { const channel = new SlackChannel(); Object.assign(channel, { botUserId: "B999" }); const result = (channel as unknown as { stripMention: (text: string) => string; }).stripMention("just a regular message"); expect(result).toBe("just a regular message"); }); }); // ── Utility: extractUserInfo ──────────────────────────────────────────────── describe("extractUserInfo", () => { it("extracts user info from message event", async () => { const channel = new SlackChannel(); const client = createMockClient(); const result = await (channel as unknown as { extractUserInfo: (event: Record, client: MockClient) => Promise; }).extractUserInfo({ user: "U123", channel: "C456", team: "T789", }, client); expect(result).toEqual({ userId: "U123", teamId: "T789", channelId: "C456", username: "U123", }); }); it("handles missing team field", async () => { const channel = new SlackChannel(); const client = createMockClient(); const result = await (channel as unknown as { extractUserInfo: (event: Record, client: MockClient) => Promise; }).extractUserInfo({ user: "U123", channel: "C456", }, client); expect(result.teamId).toBe(""); }); });