Skip to content

Commit ae3efef

Browse files
committed
Implement Slack bridge Socket Mode adapter
1 parent 3cf0cc5 commit ae3efef

2 files changed

Lines changed: 422 additions & 14 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,240 @@
11
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import type { BridgeMessage } from '@profullstack/sh1pt-core';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
24
import adapter from './index.js';
35

46
smokeTest(adapter, { idPrefix: 'bridge' });
7+
8+
const defaultSecrets = {
9+
SLACK_APP_TOKEN: 'xapp-test',
10+
SLACK_BOT_TOKEN: 'xoxb-test',
11+
};
12+
13+
const sendCtx = (secrets: Record<string, string | undefined> = defaultSecrets, dryRun = false) => ({
14+
secret: (key: string) => secrets[key],
15+
log: vi.fn(),
16+
dryRun,
17+
});
18+
19+
const subscribeCtx = (secrets: Record<string, string | undefined> = defaultSecrets, signal?: AbortSignal) => ({
20+
secret: (key: string) => secrets[key],
21+
log: vi.fn(),
22+
signal,
23+
});
24+
25+
const sampleMessage = (): BridgeMessage => ({
26+
id: 'discord-1',
27+
channel: 'general',
28+
identity: {
29+
network: 'discord',
30+
username: 'Ada',
31+
avatarUrl: 'https://avatar.example/ada.png',
32+
},
33+
text: 'hi from discord',
34+
attachments: [{
35+
url: 'https://cdn.example/file.png',
36+
filename: 'file.png',
37+
kind: 'image',
38+
mimeType: 'image/png',
39+
}],
40+
timestamp: '2026-05-21T00:00:00.000Z',
41+
originalNetwork: 'discord',
42+
});
43+
44+
class FakeSocket {
45+
static instances: FakeSocket[] = [];
46+
47+
closed = false;
48+
onmessage?: (event: { data: string }) => void | Promise<void>;
49+
sent: string[] = [];
50+
51+
constructor(public readonly url: string) {
52+
FakeSocket.instances.push(this);
53+
}
54+
55+
close(): void {
56+
this.closed = true;
57+
}
58+
59+
send(data: string): void {
60+
this.sent.push(data);
61+
}
62+
}
63+
64+
afterEach(() => {
65+
FakeSocket.instances = [];
66+
vi.restoreAllMocks();
67+
vi.unstubAllGlobals();
68+
});
69+
70+
describe('bridge-slack adapter', () => {
71+
it('requires Slack tokens for live send and subscribe', async () => {
72+
await expect(adapter.send(sendCtx({}), 'C123', sampleMessage(), {})).rejects.toThrow('SLACK_BOT_TOKEN');
73+
await expect(adapter.subscribe(subscribeCtx({ SLACK_APP_TOKEN: 'xapp-test' }), ['C123'], vi.fn(), {}))
74+
.rejects.toThrow('SLACK_APP_TOKEN + SLACK_BOT_TOKEN');
75+
});
76+
77+
it('keeps dry-run send side-effect free', async () => {
78+
const fetchMock = vi.fn();
79+
vi.stubGlobal('fetch', fetchMock);
80+
81+
await expect(adapter.send(sendCtx(defaultSecrets, true), 'C123', sampleMessage(), {})).resolves.toEqual({
82+
id: 'dry-run',
83+
});
84+
85+
expect(fetchMock).not.toHaveBeenCalled();
86+
});
87+
88+
it('posts relayed messages through chat.postMessage', async () => {
89+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
90+
ok: true,
91+
ts: '1716210001.000200',
92+
}), { status: 200 }));
93+
vi.stubGlobal('fetch', fetchMock);
94+
95+
await expect(adapter.send(sendCtx(), 'C123', sampleMessage(), {})).resolves.toEqual({
96+
id: '1716210001.000200',
97+
});
98+
99+
expect(fetchMock).toHaveBeenCalledTimes(1);
100+
const [url, request] = fetchCall(fetchMock);
101+
expect(url).toBe('https://slack.com/api/chat.postMessage');
102+
expect(request).toMatchObject({
103+
method: 'POST',
104+
headers: {
105+
authorization: 'Bearer xoxb-test',
106+
'content-type': 'application/json; charset=utf-8',
107+
},
108+
});
109+
expect(JSON.parse(String(request.body))).toEqual({
110+
channel: 'C123',
111+
text: 'Ada [discord]: hi from discord\nhttps://cdn.example/file.png',
112+
username: 'Ada [discord]',
113+
icon_url: 'https://avatar.example/ada.png',
114+
unfurl_links: false,
115+
unfurl_media: false,
116+
});
117+
});
118+
119+
it('opens Slack Socket Mode and maps subscribed message events', async () => {
120+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
121+
ok: true,
122+
url: 'wss://slack.example/socket',
123+
}), { status: 200 }));
124+
vi.stubGlobal('fetch', fetchMock);
125+
vi.stubGlobal('WebSocket', FakeSocket);
126+
127+
const onMessage = vi.fn();
128+
const subscription = await adapter.subscribe(subscribeCtx(), ['C-allowed'], onMessage, {});
129+
130+
expect(fetchMock).toHaveBeenCalledTimes(1);
131+
const [url, request] = fetchCall(fetchMock);
132+
expect(url).toBe('https://slack.com/api/apps.connections.open');
133+
expect(request).toMatchObject({
134+
method: 'POST',
135+
headers: {
136+
authorization: 'Bearer xapp-test',
137+
'content-type': 'application/json; charset=utf-8',
138+
},
139+
});
140+
141+
const socket = FakeSocket.instances[0]!;
142+
expect(socket.url).toBe('wss://slack.example/socket');
143+
144+
await socket.onmessage?.({
145+
data: JSON.stringify({
146+
envelope_id: 'env-1',
147+
type: 'events_api',
148+
payload: {
149+
event: {
150+
type: 'message',
151+
channel: 'C-other',
152+
user: 'U999',
153+
text: 'ignored',
154+
ts: '1716210000.000100',
155+
},
156+
},
157+
}),
158+
});
159+
160+
expect(socket.sent).toEqual([JSON.stringify({ envelope_id: 'env-1' })]);
161+
expect(onMessage).not.toHaveBeenCalled();
162+
163+
await socket.onmessage?.({
164+
data: JSON.stringify({
165+
envelope_id: 'env-2',
166+
type: 'events_api',
167+
payload: {
168+
event: {
169+
type: 'message',
170+
channel: 'C-allowed',
171+
bot_id: 'B123',
172+
bot_profile: {
173+
name: 'Deploy Bot',
174+
icons: { image_72: 'https://avatar.example/bot.png' },
175+
},
176+
text: 'release shipped',
177+
files: [{
178+
url_private: 'https://files.example/report.pdf',
179+
name: 'report.pdf',
180+
mimetype: 'application/pdf',
181+
}],
182+
event_ts: '1716210001.000200',
183+
ts: '1716210001.000200',
184+
},
185+
},
186+
}),
187+
});
188+
189+
expect(socket.sent).toEqual([
190+
JSON.stringify({ envelope_id: 'env-1' }),
191+
JSON.stringify({ envelope_id: 'env-2' }),
192+
]);
193+
expect(onMessage).toHaveBeenCalledWith({
194+
id: '1716210001.000200',
195+
channel: 'C-allowed',
196+
identity: {
197+
network: 'slack',
198+
username: 'Deploy Bot',
199+
avatarUrl: 'https://avatar.example/bot.png',
200+
isBot: true,
201+
},
202+
text: 'release shipped',
203+
attachments: [{
204+
url: 'https://files.example/report.pdf',
205+
filename: 'report.pdf',
206+
mimeType: 'application/pdf',
207+
kind: 'file',
208+
}],
209+
timestamp: new Date(1716210001 * 1000).toISOString(),
210+
originalNetwork: 'slack',
211+
});
212+
213+
await subscription.close();
214+
expect(socket.closed).toBe(true);
215+
});
216+
217+
it('redacts Slack tokens from API errors', async () => {
218+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
219+
ok: false,
220+
error: 'token xoxb-secret rejected',
221+
}), { status: 200 })));
222+
223+
let error: Error | undefined;
224+
try {
225+
await adapter.send(sendCtx({ SLACK_BOT_TOKEN: 'xoxb-secret' }), 'C123', sampleMessage(), {});
226+
} catch (err) {
227+
error = err as Error;
228+
}
229+
230+
expect(error).toBeInstanceOf(Error);
231+
expect(error?.message).toContain('token [redacted] rejected');
232+
expect(error?.message).not.toContain('xoxb-secret');
233+
});
234+
});
235+
236+
function fetchCall(fetchMock: ReturnType<typeof vi.fn>): [string, RequestInit] {
237+
const call = fetchMock.mock.calls[0];
238+
if (!call) throw new Error('fetch was not called');
239+
return call as unknown as [string, RequestInit];
240+
}

0 commit comments

Comments
 (0)