@crewhaus/channel-adapter-slack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -0
- package/src/fixtures/app_mention.json +18 -0
- package/src/fixtures/bot_message.json +17 -0
- package/src/fixtures/message.json +17 -0
- package/src/fixtures/url_verification.json +5 -0
- package/src/index.test.ts +365 -0
- package/src/index.ts +249 -0
- package/src/verify.ts +63 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/channel-adapter-slack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Slack channel adapter: webhook signature verification, event parsing, reply-to-thread (Section 12)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/channel-adapter-slack"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/channel-adapter-slack#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token": "Vt9PuruQ8XWLn8eK",
|
|
3
|
+
"team_id": "T12345WRK",
|
|
4
|
+
"api_app_id": "A0KRDEXAMPLE",
|
|
5
|
+
"event": {
|
|
6
|
+
"type": "app_mention",
|
|
7
|
+
"user": "U07USER01",
|
|
8
|
+
"text": "<@U0BOT> what time is it?",
|
|
9
|
+
"ts": "1700000000.000100",
|
|
10
|
+
"channel": "C0CHAN01",
|
|
11
|
+
"event_ts": "1700000000.000100",
|
|
12
|
+
"thread_ts": "1700000000.000100"
|
|
13
|
+
},
|
|
14
|
+
"type": "event_callback",
|
|
15
|
+
"event_id": "Ev0EVENTONE",
|
|
16
|
+
"event_time": 1700000000,
|
|
17
|
+
"authorizations": []
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token": "Vt9PuruQ8XWLn8eK",
|
|
3
|
+
"team_id": "T12345WRK",
|
|
4
|
+
"api_app_id": "A0KRDEXAMPLE",
|
|
5
|
+
"event": {
|
|
6
|
+
"type": "message",
|
|
7
|
+
"subtype": "bot_message",
|
|
8
|
+
"bot_id": "B0BOT01",
|
|
9
|
+
"text": "hello",
|
|
10
|
+
"ts": "1700000002.000300",
|
|
11
|
+
"channel": "C0CHAN01"
|
|
12
|
+
},
|
|
13
|
+
"type": "event_callback",
|
|
14
|
+
"event_id": "Ev0EVENTTHREE",
|
|
15
|
+
"event_time": 1700000002,
|
|
16
|
+
"authorizations": []
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token": "Vt9PuruQ8XWLn8eK",
|
|
3
|
+
"team_id": "T12345WRK",
|
|
4
|
+
"api_app_id": "A0KRDEXAMPLE",
|
|
5
|
+
"event": {
|
|
6
|
+
"type": "message",
|
|
7
|
+
"user": "U07USER01",
|
|
8
|
+
"text": "ping",
|
|
9
|
+
"ts": "1700000001.000200",
|
|
10
|
+
"channel": "C0CHAN01",
|
|
11
|
+
"event_ts": "1700000001.000200"
|
|
12
|
+
},
|
|
13
|
+
"type": "event_callback",
|
|
14
|
+
"event_id": "Ev0EVENTTWO",
|
|
15
|
+
"event_time": 1700000001,
|
|
16
|
+
"authorizations": []
|
|
17
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createSlackAdapter, signSlackBody, verifySlackSignature } from "./index";
|
|
5
|
+
|
|
6
|
+
// `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
|
|
7
|
+
// source tree so both the src and dist test copies find them.
|
|
8
|
+
const FIXTURES = join(import.meta.dir.replace(/([/\\])dist$/, "$1src"), "fixtures");
|
|
9
|
+
const APP_MENTION = readFileSync(join(FIXTURES, "app_mention.json"), "utf8");
|
|
10
|
+
const MESSAGE = readFileSync(join(FIXTURES, "message.json"), "utf8");
|
|
11
|
+
const BOT_MESSAGE = readFileSync(join(FIXTURES, "bot_message.json"), "utf8");
|
|
12
|
+
const URL_VERIFICATION = readFileSync(join(FIXTURES, "url_verification.json"), "utf8");
|
|
13
|
+
|
|
14
|
+
const SECRET = "test-secret-1234567890";
|
|
15
|
+
|
|
16
|
+
function signedHeaders(body: string, secret: string, ts: number = Math.floor(Date.now() / 1000)) {
|
|
17
|
+
const headers = new Headers();
|
|
18
|
+
headers.set("x-slack-request-timestamp", String(ts));
|
|
19
|
+
headers.set("x-slack-signature", signSlackBody({ body, timestamp: ts, signingSecret: secret }));
|
|
20
|
+
return headers;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("verifySlackSignature (T8 — security)", () => {
|
|
24
|
+
test("accepts a freshly signed request", () => {
|
|
25
|
+
const body = APP_MENTION;
|
|
26
|
+
const headers = signedHeaders(body, SECRET);
|
|
27
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects a request with a tampered body", () => {
|
|
31
|
+
const headers = signedHeaders(APP_MENTION, SECRET);
|
|
32
|
+
const tamperedBody = APP_MENTION.replace("what time is it?", "rm -rf");
|
|
33
|
+
expect(verifySlackSignature({ headers, body: tamperedBody, signingSecret: SECRET })).toBe(
|
|
34
|
+
false,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects a request with a tampered signature", () => {
|
|
39
|
+
const body = APP_MENTION;
|
|
40
|
+
const headers = signedHeaders(body, SECRET);
|
|
41
|
+
headers.set(
|
|
42
|
+
"x-slack-signature",
|
|
43
|
+
`${headers.get("x-slack-signature")?.slice(0, -2) ?? "v0="}00`,
|
|
44
|
+
);
|
|
45
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects a request signed with a different secret", () => {
|
|
49
|
+
const body = APP_MENTION;
|
|
50
|
+
const headers = signedHeaders(body, "wrong-secret");
|
|
51
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects a request with an expired timestamp (replay attack)", () => {
|
|
55
|
+
const body = APP_MENTION;
|
|
56
|
+
const oldTs = Math.floor(Date.now() / 1000) - 10 * 60; // 10 minutes ago
|
|
57
|
+
const headers = signedHeaders(body, SECRET, oldTs);
|
|
58
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("rejects a request with a future timestamp", () => {
|
|
62
|
+
const body = APP_MENTION;
|
|
63
|
+
const futureTs = Math.floor(Date.now() / 1000) + 10 * 60;
|
|
64
|
+
const headers = signedHeaders(body, SECRET, futureTs);
|
|
65
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects a request with missing timestamp header", () => {
|
|
69
|
+
const body = APP_MENTION;
|
|
70
|
+
const headers = signedHeaders(body, SECRET);
|
|
71
|
+
headers.delete("x-slack-request-timestamp");
|
|
72
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rejects a request with missing signature header", () => {
|
|
76
|
+
const body = APP_MENTION;
|
|
77
|
+
const headers = signedHeaders(body, SECRET);
|
|
78
|
+
headers.delete("x-slack-signature");
|
|
79
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("rejects a request with a malformed (non-numeric) timestamp", () => {
|
|
83
|
+
const body = APP_MENTION;
|
|
84
|
+
const headers = signedHeaders(body, SECRET);
|
|
85
|
+
headers.set("x-slack-request-timestamp", "not-a-number");
|
|
86
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects a request whose signature has the wrong length (timing-safe equal guard)", () => {
|
|
90
|
+
const body = APP_MENTION;
|
|
91
|
+
const headers = signedHeaders(body, SECRET);
|
|
92
|
+
headers.set("x-slack-signature", "v0=short");
|
|
93
|
+
expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("honours an injected `now` for tolerance testing", () => {
|
|
97
|
+
const body = APP_MENTION;
|
|
98
|
+
const fixedTs = 1700000000;
|
|
99
|
+
const headers = signedHeaders(body, SECRET, fixedTs);
|
|
100
|
+
const stillFresh = verifySlackSignature(
|
|
101
|
+
{ headers, body, signingSecret: SECRET },
|
|
102
|
+
{ now: () => fixedTs * 1000 + 60_000 },
|
|
103
|
+
);
|
|
104
|
+
expect(stillFresh).toBe(true);
|
|
105
|
+
const stale = verifySlackSignature(
|
|
106
|
+
{ headers, body, signingSecret: SECRET },
|
|
107
|
+
{ now: () => fixedTs * 1000 + 10 * 60_000 },
|
|
108
|
+
);
|
|
109
|
+
expect(stale).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("createSlackAdapter — parseInbound (T2 — contract)", () => {
|
|
114
|
+
function adapter() {
|
|
115
|
+
return createSlackAdapter({ botToken: "xoxb-test", signingSecret: SECRET });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("parses app_mention into a normalised InboundEvent", () => {
|
|
119
|
+
const out = adapter().parseInbound({ headers: new Headers(), body: APP_MENTION });
|
|
120
|
+
expect(out.kind).toBe("event");
|
|
121
|
+
if (out.kind !== "event") return;
|
|
122
|
+
expect(out.event.subtype).toBe("app_mention");
|
|
123
|
+
expect(out.event.workspaceId).toBe("T12345WRK");
|
|
124
|
+
expect(out.event.channelId).toBe("C0CHAN01");
|
|
125
|
+
expect(out.event.userId).toBe("U07USER01");
|
|
126
|
+
expect(out.event.text).toBe("<@U0BOT> what time is it?");
|
|
127
|
+
expect(out.event.threadTs).toBe("1700000000.000100");
|
|
128
|
+
expect(out.event.idempotencyKey).toBe("Ev0EVENTONE");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("parses a top-level message event", () => {
|
|
132
|
+
const out = adapter().parseInbound({ headers: new Headers(), body: MESSAGE });
|
|
133
|
+
expect(out.kind).toBe("event");
|
|
134
|
+
if (out.kind !== "event") return;
|
|
135
|
+
expect(out.event.subtype).toBe("message");
|
|
136
|
+
expect(out.event.threadTs).toBeUndefined();
|
|
137
|
+
expect(out.event.text).toBe("ping");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("skips bot self-mentions (subtype: bot_message)", () => {
|
|
141
|
+
const out = adapter().parseInbound({ headers: new Headers(), body: BOT_MESSAGE });
|
|
142
|
+
expect(out.kind).toBe("skip");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("returns a challenge for url_verification", () => {
|
|
146
|
+
const out = adapter().parseInbound({ headers: new Headers(), body: URL_VERIFICATION });
|
|
147
|
+
expect(out.kind).toBe("challenge");
|
|
148
|
+
if (out.kind !== "challenge") return;
|
|
149
|
+
expect(out.challenge.length).toBeGreaterThan(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("skips unparsable JSON", () => {
|
|
153
|
+
const out = adapter().parseInbound({ headers: new Headers(), body: "not-json" });
|
|
154
|
+
expect(out.kind).toBe("skip");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("skips events outside the {app_mention, message} set", () => {
|
|
158
|
+
const body = JSON.stringify({
|
|
159
|
+
type: "event_callback",
|
|
160
|
+
team_id: "T1",
|
|
161
|
+
event_id: "E1",
|
|
162
|
+
event: { type: "channel_created", channel: "C1", user: "U1", ts: "1.0" },
|
|
163
|
+
});
|
|
164
|
+
const out = adapter().parseInbound({ headers: new Headers(), body });
|
|
165
|
+
expect(out.kind).toBe("skip");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("skips events with another bot's bot_id when selfBotId is unspecified", () => {
|
|
169
|
+
const body = JSON.stringify({
|
|
170
|
+
type: "event_callback",
|
|
171
|
+
team_id: "T1",
|
|
172
|
+
event_id: "E1",
|
|
173
|
+
event: {
|
|
174
|
+
type: "message",
|
|
175
|
+
bot_id: "B_OTHER",
|
|
176
|
+
text: "hi",
|
|
177
|
+
channel: "C1",
|
|
178
|
+
user: "U1",
|
|
179
|
+
ts: "1.0",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const out = adapter().parseInbound({ headers: new Headers(), body });
|
|
183
|
+
expect(out.kind).toBe("skip");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("createSlackAdapter — sendReply", () => {
|
|
188
|
+
test("POSTs chat.postMessage with channel/thread_ts/text", async () => {
|
|
189
|
+
const captured: Array<{ url: string; body: string; auth: string }> = [];
|
|
190
|
+
const fakeFetch: typeof fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
191
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
192
|
+
captured.push({
|
|
193
|
+
url,
|
|
194
|
+
body: typeof init?.body === "string" ? init.body : "",
|
|
195
|
+
auth: (init?.headers as Record<string, string>)["Authorization"] ?? "",
|
|
196
|
+
});
|
|
197
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
198
|
+
status: 200,
|
|
199
|
+
headers: { "content-type": "application/json" },
|
|
200
|
+
});
|
|
201
|
+
}) as unknown as typeof fetch;
|
|
202
|
+
|
|
203
|
+
const adapter = createSlackAdapter(
|
|
204
|
+
{ botToken: "xoxb-rotated", signingSecret: SECRET },
|
|
205
|
+
{ apiBaseUrl: "https://slack.test/api", fetch: fakeFetch },
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
await adapter.sendReply({
|
|
209
|
+
event: {
|
|
210
|
+
idempotencyKey: "E1",
|
|
211
|
+
workspaceId: "T1",
|
|
212
|
+
channelId: "C1",
|
|
213
|
+
userId: "U1",
|
|
214
|
+
threadTs: "1.0",
|
|
215
|
+
ts: "1.1",
|
|
216
|
+
text: "hi",
|
|
217
|
+
subtype: "app_mention",
|
|
218
|
+
},
|
|
219
|
+
text: "hello back",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(captured.length).toBe(1);
|
|
223
|
+
expect(captured[0]?.url).toBe("https://slack.test/api/chat.postMessage");
|
|
224
|
+
expect(captured[0]?.auth).toBe("Bearer xoxb-rotated");
|
|
225
|
+
const body = JSON.parse(captured[0]?.body ?? "{}");
|
|
226
|
+
expect(body.channel).toBe("C1");
|
|
227
|
+
expect(body.thread_ts).toBe("1.0");
|
|
228
|
+
expect(body.text).toBe("hello back");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("falls back to event.ts when no threadTs is set", async () => {
|
|
232
|
+
let captured: { thread_ts?: string } = {};
|
|
233
|
+
const fakeFetch: typeof fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
234
|
+
captured = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
|
|
235
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: { "content-type": "application/json" },
|
|
238
|
+
});
|
|
239
|
+
}) as unknown as typeof fetch;
|
|
240
|
+
|
|
241
|
+
const adapter = createSlackAdapter(
|
|
242
|
+
{ botToken: "xoxb", signingSecret: SECRET },
|
|
243
|
+
{ fetch: fakeFetch },
|
|
244
|
+
);
|
|
245
|
+
await adapter.sendReply({
|
|
246
|
+
event: {
|
|
247
|
+
idempotencyKey: "E",
|
|
248
|
+
workspaceId: "T",
|
|
249
|
+
channelId: "C",
|
|
250
|
+
userId: "U",
|
|
251
|
+
ts: "9.9",
|
|
252
|
+
text: "x",
|
|
253
|
+
subtype: "message",
|
|
254
|
+
},
|
|
255
|
+
text: "out",
|
|
256
|
+
});
|
|
257
|
+
expect(captured.thread_ts).toBe("9.9");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("throws on Slack ok:false response", async () => {
|
|
261
|
+
const fakeFetch: typeof fetch = (async () =>
|
|
262
|
+
new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), {
|
|
263
|
+
status: 200,
|
|
264
|
+
headers: { "content-type": "application/json" },
|
|
265
|
+
})) as unknown as typeof fetch;
|
|
266
|
+
|
|
267
|
+
const adapter = createSlackAdapter(
|
|
268
|
+
{ botToken: "xoxb", signingSecret: SECRET },
|
|
269
|
+
{ fetch: fakeFetch },
|
|
270
|
+
);
|
|
271
|
+
await expect(
|
|
272
|
+
adapter.sendReply({
|
|
273
|
+
event: {
|
|
274
|
+
idempotencyKey: "E",
|
|
275
|
+
workspaceId: "T",
|
|
276
|
+
channelId: "C",
|
|
277
|
+
userId: "U",
|
|
278
|
+
ts: "1.0",
|
|
279
|
+
text: "x",
|
|
280
|
+
subtype: "message",
|
|
281
|
+
},
|
|
282
|
+
text: "x",
|
|
283
|
+
}),
|
|
284
|
+
).rejects.toThrow(/channel_not_found/);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("react (Phase 3 §3.2)", () => {
|
|
289
|
+
const sampleEvent = {
|
|
290
|
+
idempotencyKey: "E1",
|
|
291
|
+
workspaceId: "T1",
|
|
292
|
+
channelId: "C1",
|
|
293
|
+
userId: "U1",
|
|
294
|
+
ts: "1234.5678",
|
|
295
|
+
text: "hello",
|
|
296
|
+
subtype: "message" as const,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
test("posts to reactions.add with channel + timestamp + emoji name", async () => {
|
|
300
|
+
let capturedUrl = "";
|
|
301
|
+
let capturedBody = "";
|
|
302
|
+
const fakeFetch: typeof fetch = async (url, init) => {
|
|
303
|
+
capturedUrl = String(url);
|
|
304
|
+
capturedBody = String(init?.body ?? "");
|
|
305
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
306
|
+
status: 200,
|
|
307
|
+
headers: { "content-type": "application/json" },
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
const adapter = createSlackAdapter(
|
|
311
|
+
{ botToken: "xoxb-test", signingSecret: SECRET },
|
|
312
|
+
{ fetch: fakeFetch },
|
|
313
|
+
);
|
|
314
|
+
expect(adapter.react).toBeDefined();
|
|
315
|
+
await adapter.react?.({ event: sampleEvent, emoji: "eyes" });
|
|
316
|
+
expect(capturedUrl).toBe("https://slack.com/api/reactions.add");
|
|
317
|
+
const body = JSON.parse(capturedBody) as { channel: string; timestamp: string; name: string };
|
|
318
|
+
expect(body.channel).toBe("C1");
|
|
319
|
+
expect(body.timestamp).toBe("1234.5678");
|
|
320
|
+
expect(body.name).toBe("eyes");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("strips leading/trailing colons from emoji name", async () => {
|
|
324
|
+
let capturedBody = "";
|
|
325
|
+
const fakeFetch: typeof fetch = async (_url, init) => {
|
|
326
|
+
capturedBody = String(init?.body ?? "");
|
|
327
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
328
|
+
};
|
|
329
|
+
const adapter = createSlackAdapter(
|
|
330
|
+
{ botToken: "xoxb", signingSecret: SECRET },
|
|
331
|
+
{ fetch: fakeFetch },
|
|
332
|
+
);
|
|
333
|
+
await adapter.react?.({ event: sampleEvent, emoji: ":white_check_mark:" });
|
|
334
|
+
const body = JSON.parse(capturedBody) as { name: string };
|
|
335
|
+
expect(body.name).toBe("white_check_mark");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("treats already_reacted as benign no-op", async () => {
|
|
339
|
+
const fakeFetch: typeof fetch = async () =>
|
|
340
|
+
new Response(JSON.stringify({ ok: false, error: "already_reacted" }), {
|
|
341
|
+
status: 200,
|
|
342
|
+
headers: { "content-type": "application/json" },
|
|
343
|
+
});
|
|
344
|
+
const adapter = createSlackAdapter(
|
|
345
|
+
{ botToken: "xoxb", signingSecret: SECRET },
|
|
346
|
+
{ fetch: fakeFetch },
|
|
347
|
+
);
|
|
348
|
+
await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).resolves.toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("throws on non-already_reacted error", async () => {
|
|
352
|
+
const fakeFetch: typeof fetch = async () =>
|
|
353
|
+
new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), {
|
|
354
|
+
status: 200,
|
|
355
|
+
headers: { "content-type": "application/json" },
|
|
356
|
+
});
|
|
357
|
+
const adapter = createSlackAdapter(
|
|
358
|
+
{ botToken: "xoxb", signingSecret: SECRET },
|
|
359
|
+
{ fetch: fakeFetch },
|
|
360
|
+
);
|
|
361
|
+
await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).rejects.toThrow(
|
|
362
|
+
/invalid_auth/,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/channel-adapter-slack — Slack channel adapter for the channel
|
|
3
|
+
* target (Section 12).
|
|
4
|
+
*
|
|
5
|
+
* Implements the `ChannelAdapter` contract:
|
|
6
|
+
* verify(req) — HMAC-SHA256 of `v0:${ts}:${body}` against
|
|
7
|
+
* X-Slack-Signature, with a ±5 min replay window
|
|
8
|
+
* parseInbound(req) — normalises Slack `event_callback` envelopes into
|
|
9
|
+
* a flat `InboundEvent`. Returns null for
|
|
10
|
+
* url_verification, bot self-mentions, and types
|
|
11
|
+
* outside `app_mention` / `message`.
|
|
12
|
+
* sendReply(args) — POSTs `chat.postMessage` with a Bearer token; the
|
|
13
|
+
* api base URL is overridable via constructor opt
|
|
14
|
+
* (or `SLACK_API_BASE_URL` env) so tests can mock it.
|
|
15
|
+
* setTyping(args) — no-op placeholder (Slack has no public typing API).
|
|
16
|
+
*
|
|
17
|
+
* The adapter is idempotency-key-aware (Section 12 design review): it
|
|
18
|
+
* surfaces the inbound `event_id` so the channel-generic gateway can dedup
|
|
19
|
+
* Slack retries without coupling the gateway to Slack-specific terminology.
|
|
20
|
+
*
|
|
21
|
+
* No `@slack/*` SDK dependency — Slack's HTTP API is plain JSON, and Node's
|
|
22
|
+
* built-in `crypto` covers HMAC. Keeps the bundle slim and the trust
|
|
23
|
+
* surface minimal.
|
|
24
|
+
*/
|
|
25
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
+
import { signSlackBody, verifySlackSignature } from "./verify.js";
|
|
27
|
+
|
|
28
|
+
export { signSlackBody, verifySlackSignature } from "./verify.js";
|
|
29
|
+
|
|
30
|
+
export class SlackAdapterError extends CrewhausError {
|
|
31
|
+
override readonly name = "SlackAdapterError";
|
|
32
|
+
constructor(message: string, cause?: unknown) {
|
|
33
|
+
super("channel", message, cause);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RawRequest = {
|
|
38
|
+
readonly headers: Headers;
|
|
39
|
+
readonly body: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Channel-generic inbound event. The adapter normalises every channel's
|
|
44
|
+
* native payload (Slack `event_callback`, future Telegram update, etc.)
|
|
45
|
+
* into this flat shape. The gateway and session-router never see the raw
|
|
46
|
+
* payload — they only see this.
|
|
47
|
+
*/
|
|
48
|
+
export type InboundEvent = {
|
|
49
|
+
readonly idempotencyKey: string;
|
|
50
|
+
readonly workspaceId: string;
|
|
51
|
+
readonly channelId: string;
|
|
52
|
+
readonly userId: string;
|
|
53
|
+
readonly threadTs?: string;
|
|
54
|
+
readonly ts: string;
|
|
55
|
+
readonly text: string;
|
|
56
|
+
readonly subtype: "app_mention" | "message";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The result of `parseInbound`. Three shapes:
|
|
61
|
+
* - { kind: "event", event } — a real inbound message to route
|
|
62
|
+
* - { kind: "challenge", challenge } — Slack URL-verification handshake;
|
|
63
|
+
* gateway responds with the challenge string in plaintext
|
|
64
|
+
* - { kind: "skip" } — known-but-uninteresting payload (bot self-mention,
|
|
65
|
+
* non-message event types, etc.); gateway responds 200 and moves on
|
|
66
|
+
*/
|
|
67
|
+
export type ParsedInbound =
|
|
68
|
+
| { readonly kind: "event"; readonly event: InboundEvent }
|
|
69
|
+
| { readonly kind: "challenge"; readonly challenge: string }
|
|
70
|
+
| { readonly kind: "skip" };
|
|
71
|
+
|
|
72
|
+
export interface ChannelAdapter {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
verify(req: RawRequest): boolean;
|
|
75
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
76
|
+
sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
|
|
77
|
+
setTyping(args: { event: InboundEvent }): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Phase 3 §3.2 — add an emoji reaction to an inbound message as a
|
|
80
|
+
* lightweight status acknowledgement. Conventionally:
|
|
81
|
+
* - "eyes" (👀) on pre-tool / start
|
|
82
|
+
* - "white_check_mark" (✅) on post-tool / success
|
|
83
|
+
* - "warning" (⚠️) on need-approval / error
|
|
84
|
+
* Optional — adapters that don't support reactions (or haven't
|
|
85
|
+
* implemented yet) can leave this undefined and the runtime will
|
|
86
|
+
* skip the hook silently.
|
|
87
|
+
*/
|
|
88
|
+
react?(args: { event: InboundEvent; emoji: string }): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SlackAdapterConfig = {
|
|
92
|
+
readonly botToken: string;
|
|
93
|
+
readonly signingSecret: string;
|
|
94
|
+
readonly appToken?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type SlackAdapterOptions = {
|
|
98
|
+
readonly apiBaseUrl?: string;
|
|
99
|
+
readonly fetch?: typeof fetch;
|
|
100
|
+
readonly now?: () => number;
|
|
101
|
+
readonly selfBotId?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const DEFAULT_API_BASE_URL = "https://slack.com/api";
|
|
105
|
+
|
|
106
|
+
export function createSlackAdapter(
|
|
107
|
+
config: SlackAdapterConfig,
|
|
108
|
+
opts: SlackAdapterOptions = {},
|
|
109
|
+
): ChannelAdapter {
|
|
110
|
+
const apiBaseUrl = opts.apiBaseUrl ?? process.env["SLACK_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
111
|
+
const doFetch = opts.fetch ?? fetch;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: "slack",
|
|
115
|
+
|
|
116
|
+
verify(req: RawRequest): boolean {
|
|
117
|
+
return verifySlackSignature(
|
|
118
|
+
{ headers: req.headers, body: req.body, signingSecret: config.signingSecret },
|
|
119
|
+
opts.now !== undefined ? { now: opts.now } : {},
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
parseInbound(req: RawRequest): ParsedInbound {
|
|
124
|
+
let payload: unknown;
|
|
125
|
+
try {
|
|
126
|
+
payload = JSON.parse(req.body);
|
|
127
|
+
} catch {
|
|
128
|
+
return { kind: "skip" };
|
|
129
|
+
}
|
|
130
|
+
if (typeof payload !== "object" || payload === null) return { kind: "skip" };
|
|
131
|
+
const p = payload as Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
// URL-verification handshake (Slack sends this once when you point an
|
|
134
|
+
// app's Event Subscription at a new URL).
|
|
135
|
+
if (p["type"] === "url_verification" && typeof p["challenge"] === "string") {
|
|
136
|
+
return { kind: "challenge", challenge: p["challenge"] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (p["type"] !== "event_callback") return { kind: "skip" };
|
|
140
|
+
|
|
141
|
+
const ev = p["event"];
|
|
142
|
+
if (typeof ev !== "object" || ev === null) return { kind: "skip" };
|
|
143
|
+
const e = ev as Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
const evType = e["type"];
|
|
146
|
+
if (evType !== "app_mention" && evType !== "message") return { kind: "skip" };
|
|
147
|
+
|
|
148
|
+
// Skip self/bot loops. `bot_id` is present on Slack's bot-authored
|
|
149
|
+
// messages; `subtype: bot_message` is the older convention.
|
|
150
|
+
if (typeof e["bot_id"] === "string") {
|
|
151
|
+
if (opts.selfBotId === undefined || e["bot_id"] === opts.selfBotId) {
|
|
152
|
+
return { kind: "skip" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (e["subtype"] === "bot_message") return { kind: "skip" };
|
|
156
|
+
|
|
157
|
+
const idempotencyKey = typeof p["event_id"] === "string" ? p["event_id"] : undefined;
|
|
158
|
+
const workspaceId = typeof p["team_id"] === "string" ? p["team_id"] : undefined;
|
|
159
|
+
const channelId = typeof e["channel"] === "string" ? e["channel"] : undefined;
|
|
160
|
+
const userId = typeof e["user"] === "string" ? e["user"] : undefined;
|
|
161
|
+
const ts = typeof e["ts"] === "string" ? e["ts"] : undefined;
|
|
162
|
+
const text = typeof e["text"] === "string" ? e["text"] : "";
|
|
163
|
+
const threadTs = typeof e["thread_ts"] === "string" ? e["thread_ts"] : undefined;
|
|
164
|
+
|
|
165
|
+
if (!idempotencyKey || !workspaceId || !channelId || !userId || !ts) {
|
|
166
|
+
return { kind: "skip" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const event: InboundEvent = {
|
|
170
|
+
idempotencyKey,
|
|
171
|
+
workspaceId,
|
|
172
|
+
channelId,
|
|
173
|
+
userId,
|
|
174
|
+
...(threadTs !== undefined ? { threadTs } : {}),
|
|
175
|
+
ts,
|
|
176
|
+
text,
|
|
177
|
+
subtype: evType,
|
|
178
|
+
};
|
|
179
|
+
return { kind: "event", event };
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
|
|
183
|
+
const url = `${apiBaseUrl}/chat.postMessage`;
|
|
184
|
+
const threadKey = args.event.threadTs ?? args.event.ts;
|
|
185
|
+
const res = await doFetch(url, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
189
|
+
Authorization: `Bearer ${config.botToken}`,
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
channel: args.event.channelId,
|
|
193
|
+
thread_ts: threadKey,
|
|
194
|
+
text: args.text,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
throw new SlackAdapterError(`chat.postMessage failed: ${res.status} ${res.statusText}`);
|
|
199
|
+
}
|
|
200
|
+
// Slack returns { ok: false } at HTTP 200 on logical errors; surface that.
|
|
201
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
202
|
+
if (ct.includes("application/json")) {
|
|
203
|
+
const body = (await res.json()) as { ok?: boolean; error?: string };
|
|
204
|
+
if (body.ok === false) {
|
|
205
|
+
throw new SlackAdapterError(`chat.postMessage error: ${body.error ?? "unknown"}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async setTyping(_args: { event: InboundEvent }): Promise<void> {
|
|
211
|
+
// Slack has no public typing-indicator API for bots. No-op for v0;
|
|
212
|
+
// future versions may use the `assistant.threads.setStatus` API
|
|
213
|
+
// (Slack AI Assistant beta) for an approximation.
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// Phase 3 §3.2 — emoji reactions via reactions.add.
|
|
217
|
+
// Slack expects emoji names without colons ("eyes", not ":eyes:").
|
|
218
|
+
async react(args: { event: InboundEvent; emoji: string }): Promise<void> {
|
|
219
|
+
const emoji = args.emoji.replace(/^:|:$/g, "");
|
|
220
|
+
const url = `${apiBaseUrl}/reactions.add`;
|
|
221
|
+
const res = await doFetch(url, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
225
|
+
Authorization: `Bearer ${config.botToken}`,
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
channel: args.event.channelId,
|
|
229
|
+
timestamp: args.event.ts,
|
|
230
|
+
name: emoji,
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
if (!res.ok) {
|
|
234
|
+
// Reactions are best-effort — log via thrown error but the
|
|
235
|
+
// session-router catches and continues so a flaky API doesn't
|
|
236
|
+
// abort message processing.
|
|
237
|
+
throw new SlackAdapterError(`reactions.add failed: ${res.status} ${res.statusText}`);
|
|
238
|
+
}
|
|
239
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
240
|
+
if (ct.includes("application/json")) {
|
|
241
|
+
const body = (await res.json()) as { ok?: boolean; error?: string };
|
|
242
|
+
// "already_reacted" is a benign no-op; don't surface as error.
|
|
243
|
+
if (body.ok === false && body.error !== "already_reacted") {
|
|
244
|
+
throw new SlackAdapterError(`reactions.add error: ${body.error ?? "unknown"}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify a Slack webhook signature per
|
|
5
|
+
* https://api.slack.com/authentication/verifying-requests-from-slack.
|
|
6
|
+
*
|
|
7
|
+
* Signing-base string is `v0:${timestamp}:${body}`. HMAC-SHA256 with the
|
|
8
|
+
* signing secret, hex-encoded, with `v0=` prefix. Compared via
|
|
9
|
+
* `timingSafeEqual` (constant-time, prevents nibble-leak side channels).
|
|
10
|
+
*
|
|
11
|
+
* The 5-minute timestamp tolerance is the Slack-recommended replay-window
|
|
12
|
+
* cap; without it a captured signed payload could be re-played indefinitely.
|
|
13
|
+
*
|
|
14
|
+
* Returns false on missing headers, malformed timestamp, mismatched
|
|
15
|
+
* signature, or stale/future timestamp.
|
|
16
|
+
*/
|
|
17
|
+
export type VerifyArgs = {
|
|
18
|
+
readonly headers: Headers;
|
|
19
|
+
readonly body: string;
|
|
20
|
+
readonly signingSecret: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type VerifyOptions = {
|
|
24
|
+
readonly now?: () => number;
|
|
25
|
+
readonly toleranceMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
export function verifySlackSignature(args: VerifyArgs, opts: VerifyOptions = {}): boolean {
|
|
31
|
+
const now = opts.now ?? (() => Date.now());
|
|
32
|
+
const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
|
|
33
|
+
|
|
34
|
+
const timestamp = args.headers.get("x-slack-request-timestamp");
|
|
35
|
+
const signature = args.headers.get("x-slack-signature");
|
|
36
|
+
if (!timestamp || !signature) return false;
|
|
37
|
+
|
|
38
|
+
const tsNum = Number.parseInt(timestamp, 10);
|
|
39
|
+
if (!Number.isFinite(tsNum)) return false;
|
|
40
|
+
if (Math.abs(now() - tsNum * 1000) > tolerance) return false;
|
|
41
|
+
|
|
42
|
+
const base = `v0:${timestamp}:${args.body}`;
|
|
43
|
+
const expected = `v0=${createHmac("sha256", args.signingSecret).update(base).digest("hex")}`;
|
|
44
|
+
|
|
45
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
46
|
+
const actualBuf = Buffer.from(signature, "utf8");
|
|
47
|
+
if (expectedBuf.length !== actualBuf.length) return false;
|
|
48
|
+
return timingSafeEqual(expectedBuf, actualBuf);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute a valid `X-Slack-Signature` for a body — used by smoke tests and
|
|
53
|
+
* the in-repo integration test to construct fixtures the daemon will accept.
|
|
54
|
+
* Production code should NEVER call this — Slack signs requests, not us.
|
|
55
|
+
*/
|
|
56
|
+
export function signSlackBody(args: {
|
|
57
|
+
body: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
signingSecret: string;
|
|
60
|
+
}): string {
|
|
61
|
+
const base = `v0:${args.timestamp}:${args.body}`;
|
|
62
|
+
return `v0=${createHmac("sha256", args.signingSecret).update(base).digest("hex")}`;
|
|
63
|
+
}
|