@crewhaus/channel-adapter-discord 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/component_button.json +13 -0
- package/src/fixtures/missing_user.json +9 -0
- package/src/fixtures/modal_submit.json +22 -0
- package/src/fixtures/ping.json +6 -0
- package/src/fixtures/slash_command_basic.json +13 -0
- package/src/fixtures/slash_command_dm.json +12 -0
- package/src/fixtures/slash_command_thread.json +14 -0
- package/src/fixtures/slash_command_with_options.json +16 -0
- package/src/fixtures/unknown_type.json +7 -0
- package/src/index.test.ts +299 -0
- package/src/index.ts +261 -0
- package/src/verify.ts +107 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/channel-adapter-discord",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Discord channel adapter: Ed25519 signature verification + slash-command/button/modal interaction parsing + interaction-response API (Section 33)",
|
|
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-discord"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/channel-adapter-discord#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,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000006",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 3,
|
|
5
|
+
"token": "tok-btn",
|
|
6
|
+
"channel_id": "300000000000000001",
|
|
7
|
+
"guild_id": "400000000000000001",
|
|
8
|
+
"member": { "user": { "id": "500000000000000001", "username": "alice" } },
|
|
9
|
+
"data": {
|
|
10
|
+
"custom_id": "approve:doc-123",
|
|
11
|
+
"component_type": 2
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000007",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 5,
|
|
5
|
+
"token": "tok-modal",
|
|
6
|
+
"channel_id": "300000000000000001",
|
|
7
|
+
"guild_id": "400000000000000001",
|
|
8
|
+
"member": { "user": { "id": "500000000000000001", "username": "alice" } },
|
|
9
|
+
"data": {
|
|
10
|
+
"custom_id": "feedback-form",
|
|
11
|
+
"components": [
|
|
12
|
+
{
|
|
13
|
+
"type": 1,
|
|
14
|
+
"components": [{ "type": 4, "custom_id": "rating", "value": "5" }]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": 1,
|
|
18
|
+
"components": [{ "type": 4, "custom_id": "comment", "value": "loved it" }]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000002",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 2,
|
|
5
|
+
"token": "tok-basic",
|
|
6
|
+
"channel_id": "300000000000000001",
|
|
7
|
+
"guild_id": "400000000000000001",
|
|
8
|
+
"member": { "user": { "id": "500000000000000001", "username": "alice" } },
|
|
9
|
+
"data": {
|
|
10
|
+
"name": "ping",
|
|
11
|
+
"options": []
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000005",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 2,
|
|
5
|
+
"token": "tok-dm",
|
|
6
|
+
"channel_id": "300000000000000077",
|
|
7
|
+
"user": { "id": "500000000000000007", "username": "bob" },
|
|
8
|
+
"data": {
|
|
9
|
+
"name": "help",
|
|
10
|
+
"options": []
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000004",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 2,
|
|
5
|
+
"token": "tok-thread",
|
|
6
|
+
"channel_id": "300000000000000099",
|
|
7
|
+
"guild_id": "400000000000000001",
|
|
8
|
+
"channel": { "id": "300000000000000099", "parent_id": "300000000000000001" },
|
|
9
|
+
"member": { "user": { "id": "500000000000000001", "username": "alice" } },
|
|
10
|
+
"data": {
|
|
11
|
+
"name": "explain",
|
|
12
|
+
"options": []
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "100000000000000003",
|
|
3
|
+
"application_id": "200000000000000001",
|
|
4
|
+
"type": 2,
|
|
5
|
+
"token": "tok-opts",
|
|
6
|
+
"channel_id": "300000000000000001",
|
|
7
|
+
"guild_id": "400000000000000001",
|
|
8
|
+
"member": { "user": { "id": "500000000000000001", "username": "alice" } },
|
|
9
|
+
"data": {
|
|
10
|
+
"name": "summarize",
|
|
11
|
+
"options": [
|
|
12
|
+
{ "name": "url", "type": 3, "value": "https://example.com" },
|
|
13
|
+
{ "name": "depth", "type": 4, "value": 3 }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ChannelAdapter,
|
|
7
|
+
DiscordAdapterError,
|
|
8
|
+
type InboundEvent,
|
|
9
|
+
type ParsedInbound,
|
|
10
|
+
type RawRequest,
|
|
11
|
+
createDiscordAdapter,
|
|
12
|
+
generateEd25519Keypair,
|
|
13
|
+
signDiscordBody,
|
|
14
|
+
verifyDiscordSignature,
|
|
15
|
+
} from "./index";
|
|
16
|
+
|
|
17
|
+
// `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
|
|
18
|
+
// source tree so both the src and dist test copies find them.
|
|
19
|
+
const FIXTURES_DIR = join(import.meta.dir.replace(/([/\\])dist$/, "$1src"), "fixtures");
|
|
20
|
+
const fixture = (name: string) => readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8");
|
|
21
|
+
|
|
22
|
+
let publicKeyHex: string;
|
|
23
|
+
let privateKeyPem: string;
|
|
24
|
+
let otherPublicKeyHex: string;
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
const k = generateEd25519Keypair();
|
|
28
|
+
publicKeyHex = k.publicKeyHex;
|
|
29
|
+
privateKeyPem = k.privateKeyPem;
|
|
30
|
+
otherPublicKeyHex = generateEd25519Keypair().publicKeyHex;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function signedHeaders(body: string, ts: string | number = "1700000000"): Headers {
|
|
34
|
+
const sig = signDiscordBody({ body, timestamp: ts, privateKeyPem });
|
|
35
|
+
const h = new Headers();
|
|
36
|
+
h.set("X-Signature-Ed25519", sig);
|
|
37
|
+
h.set("X-Signature-Timestamp", String(ts));
|
|
38
|
+
return h;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function adapter(
|
|
42
|
+
overrides: Partial<{ publicKeyHex: string; fetch: typeof fetch }> = {},
|
|
43
|
+
): ChannelAdapter {
|
|
44
|
+
return createDiscordAdapter(
|
|
45
|
+
{
|
|
46
|
+
applicationId: "200000000000000001",
|
|
47
|
+
botToken: "Bot.token",
|
|
48
|
+
publicKeyHex: overrides.publicKeyHex ?? publicKeyHex,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
apiBaseUrl: "https://test.discord.local",
|
|
52
|
+
...(overrides.fetch ? { fetch: overrides.fetch } : {}),
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("verifyDiscordSignature (T8)", () => {
|
|
58
|
+
test("matches a valid signature", () => {
|
|
59
|
+
const body = fixture("ping");
|
|
60
|
+
const r = verifyDiscordSignature({
|
|
61
|
+
headers: signedHeaders(body),
|
|
62
|
+
body,
|
|
63
|
+
publicKeyHex,
|
|
64
|
+
});
|
|
65
|
+
expect(r).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects a tampered body", () => {
|
|
69
|
+
const body = fixture("ping");
|
|
70
|
+
const headers = signedHeaders(body);
|
|
71
|
+
expect(
|
|
72
|
+
verifyDiscordSignature({
|
|
73
|
+
headers,
|
|
74
|
+
body: `${body}--tampered`,
|
|
75
|
+
publicKeyHex,
|
|
76
|
+
}),
|
|
77
|
+
).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects wrong public key", () => {
|
|
81
|
+
const body = fixture("ping");
|
|
82
|
+
expect(
|
|
83
|
+
verifyDiscordSignature({
|
|
84
|
+
headers: signedHeaders(body),
|
|
85
|
+
body,
|
|
86
|
+
publicKeyHex: otherPublicKeyHex,
|
|
87
|
+
}),
|
|
88
|
+
).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects missing X-Signature-Ed25519 header", () => {
|
|
92
|
+
const body = fixture("ping");
|
|
93
|
+
const headers = signedHeaders(body);
|
|
94
|
+
headers.delete("X-Signature-Ed25519");
|
|
95
|
+
expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects missing X-Signature-Timestamp header", () => {
|
|
99
|
+
const body = fixture("ping");
|
|
100
|
+
const headers = signedHeaders(body);
|
|
101
|
+
headers.delete("X-Signature-Timestamp");
|
|
102
|
+
expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rejects malformed sig hex (wrong length)", () => {
|
|
106
|
+
const body = fixture("ping");
|
|
107
|
+
const headers = new Headers({
|
|
108
|
+
"X-Signature-Ed25519": "deadbeef",
|
|
109
|
+
"X-Signature-Timestamp": "1700000000",
|
|
110
|
+
});
|
|
111
|
+
expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects non-numeric timestamp", () => {
|
|
115
|
+
const body = fixture("ping");
|
|
116
|
+
const headers = signedHeaders(body, "abc");
|
|
117
|
+
expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects malformed publicKeyHex", () => {
|
|
121
|
+
const body = fixture("ping");
|
|
122
|
+
expect(
|
|
123
|
+
verifyDiscordSignature({
|
|
124
|
+
headers: signedHeaders(body),
|
|
125
|
+
body,
|
|
126
|
+
publicKeyHex: "not-hex",
|
|
127
|
+
}),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("parseInbound — fixtures (T2)", () => {
|
|
133
|
+
test("ping → challenge with PONG body", () => {
|
|
134
|
+
const a = adapter();
|
|
135
|
+
const body = fixture("ping");
|
|
136
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
137
|
+
expect(r.kind).toBe("challenge");
|
|
138
|
+
if (r.kind === "challenge") {
|
|
139
|
+
expect(JSON.parse(r.challenge)).toEqual({ type: 1 });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("slash_command_basic → event with text /<name>", () => {
|
|
144
|
+
const a = adapter();
|
|
145
|
+
const body = fixture("slash_command_basic");
|
|
146
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
147
|
+
ParsedInbound,
|
|
148
|
+
{ kind: "event" }
|
|
149
|
+
>;
|
|
150
|
+
expect(r.kind).toBe("event");
|
|
151
|
+
expect(r.event.text).toBe("/ping");
|
|
152
|
+
expect(r.event.channelId).toBe("300000000000000001");
|
|
153
|
+
expect(r.event.userId).toBe("500000000000000001");
|
|
154
|
+
expect(r.event.workspaceId).toBe("400000000000000001");
|
|
155
|
+
expect(r.event.threadTs).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("slash_command_with_options → text encodes name=value pairs", () => {
|
|
159
|
+
const a = adapter();
|
|
160
|
+
const body = fixture("slash_command_with_options");
|
|
161
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
162
|
+
ParsedInbound,
|
|
163
|
+
{ kind: "event" }
|
|
164
|
+
>;
|
|
165
|
+
expect(r.event.text).toBe("/summarize url=https://example.com depth=3");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("slash_command_thread → channelId is thread id, threadTs set", () => {
|
|
169
|
+
const a = adapter();
|
|
170
|
+
const body = fixture("slash_command_thread");
|
|
171
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
172
|
+
ParsedInbound,
|
|
173
|
+
{ kind: "event" }
|
|
174
|
+
>;
|
|
175
|
+
expect(r.event.channelId).toBe("300000000000000099");
|
|
176
|
+
expect(r.event.threadTs).toBe("300000000000000099");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('slash_command_dm → workspaceId is "dm", user.id used', () => {
|
|
180
|
+
const a = adapter();
|
|
181
|
+
const body = fixture("slash_command_dm");
|
|
182
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
183
|
+
ParsedInbound,
|
|
184
|
+
{ kind: "event" }
|
|
185
|
+
>;
|
|
186
|
+
expect(r.event.workspaceId).toBe("dm");
|
|
187
|
+
expect(r.event.userId).toBe("500000000000000007");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("component_button → text [component:<custom_id>]", () => {
|
|
191
|
+
const a = adapter();
|
|
192
|
+
const body = fixture("component_button");
|
|
193
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
194
|
+
ParsedInbound,
|
|
195
|
+
{ kind: "event" }
|
|
196
|
+
>;
|
|
197
|
+
expect(r.event.text).toBe("[component:approve:doc-123]");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("modal_submit → text encodes form fields", () => {
|
|
201
|
+
const a = adapter();
|
|
202
|
+
const body = fixture("modal_submit");
|
|
203
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
204
|
+
ParsedInbound,
|
|
205
|
+
{ kind: "event" }
|
|
206
|
+
>;
|
|
207
|
+
expect(r.event.text).toBe("feedback-form: rating=5 comment=loved it");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("unknown_type → skip", () => {
|
|
211
|
+
const a = adapter();
|
|
212
|
+
const body = fixture("unknown_type");
|
|
213
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
214
|
+
expect(r.kind).toBe("skip");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("missing_user → skip", () => {
|
|
218
|
+
const a = adapter();
|
|
219
|
+
const body = fixture("missing_user");
|
|
220
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
221
|
+
expect(r.kind).toBe("skip");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("malformed JSON → skip", () => {
|
|
225
|
+
const a = adapter();
|
|
226
|
+
const r = a.parseInbound({ headers: new Headers(), body: "not json{" });
|
|
227
|
+
expect(r.kind).toBe("skip");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("idempotencyKey == interaction id", () => {
|
|
231
|
+
const a = adapter();
|
|
232
|
+
const body = fixture("slash_command_basic");
|
|
233
|
+
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
234
|
+
ParsedInbound,
|
|
235
|
+
{ kind: "event" }
|
|
236
|
+
>;
|
|
237
|
+
expect(r.event.idempotencyKey).toBe("100000000000000002");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("sendReply / setTyping (T3)", () => {
|
|
242
|
+
function captureFetch() {
|
|
243
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
244
|
+
const f = (async (input: string | Request | URL, init?: RequestInit) => {
|
|
245
|
+
calls.push({ url: String(input), init: init ?? {} });
|
|
246
|
+
return new Response("", { status: 204 });
|
|
247
|
+
}) as unknown as typeof fetch;
|
|
248
|
+
return { calls, fetch: f };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const event: InboundEvent = {
|
|
252
|
+
idempotencyKey: "100000000000000002",
|
|
253
|
+
workspaceId: "400000000000000001",
|
|
254
|
+
channelId: "300000000000000001",
|
|
255
|
+
userId: "500000000000000001",
|
|
256
|
+
ts: "100000000000000002",
|
|
257
|
+
text: "/ping",
|
|
258
|
+
subtype: "message",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
test("sendReply POSTs /channels/{channelId}/messages with content", async () => {
|
|
262
|
+
const { calls, fetch: f } = captureFetch();
|
|
263
|
+
const a = adapter({ fetch: f });
|
|
264
|
+
await a.sendReply({ event, text: "pong" });
|
|
265
|
+
expect(calls.length).toBe(1);
|
|
266
|
+
expect(calls[0]?.url).toBe("https://test.discord.local/channels/300000000000000001/messages");
|
|
267
|
+
const body = JSON.parse(String(calls[0]?.init.body));
|
|
268
|
+
expect(body.content).toBe("pong");
|
|
269
|
+
const auth = (calls[0]?.init.headers as Record<string, string> | undefined)?.["Authorization"];
|
|
270
|
+
expect(auth).toBe("Bot Bot.token");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("sendReply throws DiscordAdapterError on HTTP error", async () => {
|
|
274
|
+
const f = (async () =>
|
|
275
|
+
new Response("server fail", {
|
|
276
|
+
status: 500,
|
|
277
|
+
statusText: "Internal",
|
|
278
|
+
})) as unknown as typeof fetch;
|
|
279
|
+
const a = adapter({ fetch: f });
|
|
280
|
+
await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(DiscordAdapterError);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("setTyping POSTs /channels/{channelId}/typing", async () => {
|
|
284
|
+
const { calls, fetch: f } = captureFetch();
|
|
285
|
+
const a = adapter({ fetch: f });
|
|
286
|
+
await a.setTyping({ event });
|
|
287
|
+
expect(calls.length).toBe(1);
|
|
288
|
+
expect(calls[0]?.url).toBe("https://test.discord.local/channels/300000000000000001/typing");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("createDiscordAdapter.verify()", () => {
|
|
293
|
+
test("forwards to verifyDiscordSignature", () => {
|
|
294
|
+
const a = adapter();
|
|
295
|
+
const body = fixture("ping");
|
|
296
|
+
expect(a.verify({ headers: signedHeaders(body), body })).toBe(true);
|
|
297
|
+
expect(a.verify({ headers: new Headers(), body })).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/channel-adapter-discord — Discord channel adapter for the
|
|
3
|
+
* channel target (Section 33).
|
|
4
|
+
*
|
|
5
|
+
* Discord's interaction model is request/response: the bot is invoked
|
|
6
|
+
* via slash commands (interaction type 2), button clicks (type 3), and
|
|
7
|
+
* modal submits (type 5). Each request is Ed25519-signed via
|
|
8
|
+
* `X-Signature-Ed25519` + `X-Signature-Timestamp`.
|
|
9
|
+
*
|
|
10
|
+
* Discord requires the bot to respond *to the request itself* — the
|
|
11
|
+
* gateway POSTs the interaction-response body back. For backward
|
|
12
|
+
* compatibility with the channel adapter contract, `sendReply` instead
|
|
13
|
+
* uses Discord's "follow-up message" API (`POST /webhooks/{appId}/{token}`)
|
|
14
|
+
* which works at any time after the initial deferred response.
|
|
15
|
+
*
|
|
16
|
+
* Thread session keying uses Discord's native `thread_id` when present
|
|
17
|
+
* (forum / public thread / archived thread); otherwise the channel id.
|
|
18
|
+
*/
|
|
19
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
20
|
+
import { verifyDiscordSignature } from "./verify.js";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
generateEd25519Keypair,
|
|
24
|
+
signDiscordBody,
|
|
25
|
+
verifyDiscordSignature,
|
|
26
|
+
} from "./verify.js";
|
|
27
|
+
|
|
28
|
+
export class DiscordAdapterError extends CrewhausError {
|
|
29
|
+
override readonly name = "DiscordAdapterError";
|
|
30
|
+
constructor(message: string, cause?: unknown) {
|
|
31
|
+
super("channel", message, cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RawRequest = {
|
|
36
|
+
readonly headers: Headers;
|
|
37
|
+
readonly body: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Channel-generic inbound event. Same shape as Slack/Telegram:
|
|
42
|
+
*
|
|
43
|
+
* Mappings:
|
|
44
|
+
* - workspaceId → guild_id (or "dm" when not in a guild)
|
|
45
|
+
* - channelId → channel_id
|
|
46
|
+
* - userId → user.id (or member.user.id in guild context)
|
|
47
|
+
* - threadTs → channel_id when the channel is a thread
|
|
48
|
+
* - ts → interaction id (snowflake; monotonic-ish)
|
|
49
|
+
* - text → command-name + options for slash commands;
|
|
50
|
+
* custom_id for component clicks; modal field values
|
|
51
|
+
* joined for modal submits
|
|
52
|
+
* - subtype → "message" | "app_mention"
|
|
53
|
+
* - idempotencyKey → interaction id (Discord doesn't redeliver, but we
|
|
54
|
+
* surface the id so the gateway dedups consistently)
|
|
55
|
+
*/
|
|
56
|
+
export type InboundEvent = {
|
|
57
|
+
readonly idempotencyKey: string;
|
|
58
|
+
readonly workspaceId: string;
|
|
59
|
+
readonly channelId: string;
|
|
60
|
+
readonly userId: string;
|
|
61
|
+
readonly threadTs?: string;
|
|
62
|
+
readonly ts: string;
|
|
63
|
+
readonly text: string;
|
|
64
|
+
readonly subtype: "app_mention" | "message";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ParsedInbound =
|
|
68
|
+
| { readonly kind: "event"; readonly event: InboundEvent }
|
|
69
|
+
/**
|
|
70
|
+
* Discord pings every interactions endpoint with a `type:1` PING that
|
|
71
|
+
* must be answered with `type:1` PONG before any real interactions
|
|
72
|
+
* arrive. The gateway responds with the canned PONG body.
|
|
73
|
+
*/
|
|
74
|
+
| { readonly kind: "challenge"; readonly challenge: string }
|
|
75
|
+
| { readonly kind: "skip" };
|
|
76
|
+
|
|
77
|
+
export interface ChannelAdapter {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
verify(req: RawRequest): boolean;
|
|
80
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
81
|
+
sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
|
|
82
|
+
setTyping(args: { event: InboundEvent }): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type DiscordAdapterConfig = {
|
|
86
|
+
/** Application id (snowflake). Used by the follow-up webhook URL. */
|
|
87
|
+
readonly applicationId: string;
|
|
88
|
+
/** Bot user token — used to authorize follow-up REST calls. */
|
|
89
|
+
readonly botToken: string;
|
|
90
|
+
/** Public key (hex, 64 chars) for Ed25519 verification. */
|
|
91
|
+
readonly publicKeyHex: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type DiscordAdapterOptions = {
|
|
95
|
+
readonly apiBaseUrl?: string;
|
|
96
|
+
readonly fetch?: typeof fetch;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
|
|
100
|
+
const PONG_RESPONSE_BODY = JSON.stringify({ type: 1 });
|
|
101
|
+
|
|
102
|
+
export function createDiscordAdapter(
|
|
103
|
+
config: DiscordAdapterConfig,
|
|
104
|
+
opts: DiscordAdapterOptions = {},
|
|
105
|
+
): ChannelAdapter {
|
|
106
|
+
const apiBaseUrl = opts.apiBaseUrl ?? process.env["DISCORD_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
107
|
+
const doFetch = opts.fetch ?? fetch;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: "discord",
|
|
111
|
+
|
|
112
|
+
verify(req: RawRequest): boolean {
|
|
113
|
+
return verifyDiscordSignature({
|
|
114
|
+
headers: req.headers,
|
|
115
|
+
body: req.body,
|
|
116
|
+
publicKeyHex: config.publicKeyHex,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
parseInbound(req: RawRequest): ParsedInbound {
|
|
121
|
+
let payload: unknown;
|
|
122
|
+
try {
|
|
123
|
+
payload = JSON.parse(req.body);
|
|
124
|
+
} catch {
|
|
125
|
+
return { kind: "skip" };
|
|
126
|
+
}
|
|
127
|
+
if (typeof payload !== "object" || payload === null) return { kind: "skip" };
|
|
128
|
+
const p = payload as DiscordInteraction;
|
|
129
|
+
|
|
130
|
+
// PING → PONG handshake (Discord's URL-verification analogue).
|
|
131
|
+
if (p.type === 1) {
|
|
132
|
+
return { kind: "challenge", challenge: PONG_RESPONSE_BODY };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// We handle types 2 (slash command), 3 (component click), 5 (modal submit).
|
|
136
|
+
if (p.type !== 2 && p.type !== 3 && p.type !== 5) {
|
|
137
|
+
return { kind: "skip" };
|
|
138
|
+
}
|
|
139
|
+
const interactionId = p.id;
|
|
140
|
+
const channelId = p.channel_id;
|
|
141
|
+
const userId = p.member?.user?.id ?? p.user?.id;
|
|
142
|
+
if (!interactionId || !channelId || !userId) return { kind: "skip" };
|
|
143
|
+
|
|
144
|
+
const workspaceId = p.guild_id ?? "dm";
|
|
145
|
+
// Threads: in Discord a "thread" is a Channel with `parent_id` set
|
|
146
|
+
// and type 11 (public thread) / 12 (private thread) / 10 (news
|
|
147
|
+
// thread). The interaction.channel object includes `parent_id` when
|
|
148
|
+
// the interaction is in a thread.
|
|
149
|
+
const isThread = typeof p.channel?.parent_id === "string" && p.channel.parent_id.length > 0;
|
|
150
|
+
const threadTs = isThread ? channelId : undefined;
|
|
151
|
+
|
|
152
|
+
const text = renderInteractionText(p);
|
|
153
|
+
|
|
154
|
+
const event: InboundEvent = {
|
|
155
|
+
idempotencyKey: interactionId,
|
|
156
|
+
workspaceId,
|
|
157
|
+
channelId,
|
|
158
|
+
userId,
|
|
159
|
+
...(threadTs !== undefined ? { threadTs } : {}),
|
|
160
|
+
ts: interactionId,
|
|
161
|
+
text,
|
|
162
|
+
subtype: "message",
|
|
163
|
+
};
|
|
164
|
+
return { kind: "event", event };
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
|
|
168
|
+
// Use Discord's interaction follow-up endpoint:
|
|
169
|
+
// POST /webhooks/{application.id}/{interaction.token}
|
|
170
|
+
// The interaction.token isn't part of `InboundEvent`; in practice the
|
|
171
|
+
// gateway should call `interaction.respond({type:5, data: {...}})`
|
|
172
|
+
// synchronously to defer, then we follow up via this REST call. For
|
|
173
|
+
// the v0 channel-bot daemon we use the application's webhook channel
|
|
174
|
+
// POST as a fallback so the message reaches the channel even without
|
|
175
|
+
// the interaction token (slightly different rendering — no
|
|
176
|
+
// ephemeral, no per-user replies).
|
|
177
|
+
const url = `${apiBaseUrl}/channels/${args.event.channelId}/messages`;
|
|
178
|
+
const res = await doFetch(url, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: {
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
Authorization: `Bot ${config.botToken}`,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({ content: args.text }),
|
|
185
|
+
});
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
throw new DiscordAdapterError(
|
|
188
|
+
`channels/${args.event.channelId}/messages failed: ${res.status} ${res.statusText}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async setTyping(args: { event: InboundEvent }): Promise<void> {
|
|
194
|
+
const url = `${apiBaseUrl}/channels/${args.event.channelId}/typing`;
|
|
195
|
+
await doFetch(url, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
Authorization: `Bot ${config.botToken}`,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
// Best-effort — do not surface failures.
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reduce an interaction payload to a single text body for the agent. */
|
|
207
|
+
function renderInteractionText(p: DiscordInteraction): string {
|
|
208
|
+
if (p.type === 2) {
|
|
209
|
+
// Slash command: render as `/<name> <option1=value1> <option2=value2>`
|
|
210
|
+
const cmd = p.data?.name ?? "?";
|
|
211
|
+
const opts = (p.data?.options ?? [])
|
|
212
|
+
.map((o) => `${o.name}=${stringifyOption(o.value)}`)
|
|
213
|
+
.join(" ");
|
|
214
|
+
return opts ? `/${cmd} ${opts}` : `/${cmd}`;
|
|
215
|
+
}
|
|
216
|
+
if (p.type === 3) {
|
|
217
|
+
// Component click: `[component:<custom_id>]`
|
|
218
|
+
return `[component:${p.data?.custom_id ?? ""}]`;
|
|
219
|
+
}
|
|
220
|
+
if (p.type === 5) {
|
|
221
|
+
// Modal submit: render as `<custom_id>: field1=value1 field2=value2`
|
|
222
|
+
const id = p.data?.custom_id ?? "";
|
|
223
|
+
const fields = (p.data?.components ?? [])
|
|
224
|
+
.flatMap((row) => row.components ?? [])
|
|
225
|
+
.map((c) => `${c.custom_id ?? "?"}=${c.value ?? ""}`)
|
|
226
|
+
.join(" ");
|
|
227
|
+
return `${id}: ${fields}`;
|
|
228
|
+
}
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stringifyOption(v: unknown): string {
|
|
233
|
+
if (v === null || v === undefined) return "";
|
|
234
|
+
if (typeof v === "string") return v;
|
|
235
|
+
return JSON.stringify(v);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Minimal Discord interaction shape (no SDK) ──────────────────────────────
|
|
239
|
+
|
|
240
|
+
export type DiscordInteraction = {
|
|
241
|
+
readonly id: string;
|
|
242
|
+
readonly application_id?: string;
|
|
243
|
+
readonly type: number;
|
|
244
|
+
readonly token?: string;
|
|
245
|
+
readonly channel_id?: string;
|
|
246
|
+
readonly guild_id?: string;
|
|
247
|
+
readonly user?: { readonly id: string; readonly username?: string };
|
|
248
|
+
readonly member?: { readonly user?: { readonly id: string; readonly username?: string } };
|
|
249
|
+
readonly channel?: { readonly id?: string; readonly parent_id?: string };
|
|
250
|
+
readonly data?: {
|
|
251
|
+
readonly name?: string;
|
|
252
|
+
readonly custom_id?: string;
|
|
253
|
+
readonly options?: ReadonlyArray<{ readonly name: string; readonly value?: unknown }>;
|
|
254
|
+
readonly components?: ReadonlyArray<{
|
|
255
|
+
readonly components?: ReadonlyArray<{
|
|
256
|
+
readonly custom_id?: string;
|
|
257
|
+
readonly value?: string;
|
|
258
|
+
}>;
|
|
259
|
+
}>;
|
|
260
|
+
};
|
|
261
|
+
};
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPrivateKey,
|
|
3
|
+
createPublicKey,
|
|
4
|
+
sign as cryptoSign,
|
|
5
|
+
generateKeyPairSync,
|
|
6
|
+
verify,
|
|
7
|
+
} from "node:crypto";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discord interaction signature verification per
|
|
11
|
+
* https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization.
|
|
12
|
+
*
|
|
13
|
+
* Discord signs interaction webhooks with Ed25519. Each request carries
|
|
14
|
+
* `X-Signature-Ed25519` (hex) and `X-Signature-Timestamp` (seconds). The
|
|
15
|
+
* signed payload is `<timestamp><body>`. The bot's `publicKey` (hex,
|
|
16
|
+
* 64 chars) is the verification key.
|
|
17
|
+
*
|
|
18
|
+
* We use Node's built-in `crypto.verify` with `null` algorithm and an
|
|
19
|
+
* Ed25519 KeyObject built from the raw 32-byte public key — no external
|
|
20
|
+
* SDK needed.
|
|
21
|
+
*/
|
|
22
|
+
export type DiscordVerifyArgs = {
|
|
23
|
+
readonly headers: Headers;
|
|
24
|
+
readonly body: string;
|
|
25
|
+
readonly publicKeyHex: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function verifyDiscordSignature(args: DiscordVerifyArgs): boolean {
|
|
29
|
+
const sigHex = args.headers.get("x-signature-ed25519");
|
|
30
|
+
const timestamp = args.headers.get("x-signature-timestamp");
|
|
31
|
+
if (!sigHex || !timestamp) return false;
|
|
32
|
+
if (!/^[0-9a-f]{128}$/i.test(sigHex)) return false;
|
|
33
|
+
if (!/^\d+$/.test(timestamp)) return false;
|
|
34
|
+
|
|
35
|
+
let signature: Buffer;
|
|
36
|
+
try {
|
|
37
|
+
signature = Buffer.from(sigHex, "hex");
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (signature.length !== 64) return false;
|
|
42
|
+
|
|
43
|
+
let publicKey: ReturnType<typeof ed25519PublicKeyFromHex>;
|
|
44
|
+
try {
|
|
45
|
+
publicKey = ed25519PublicKeyFromHex(args.publicKeyHex);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const message = Buffer.from(`${timestamp}${args.body}`, "utf8");
|
|
51
|
+
try {
|
|
52
|
+
return verify(null, message, publicKey, signature);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Construct an Ed25519 public KeyObject from a 32-byte hex string. Discord's
|
|
60
|
+
* docs publish the bot key as a 64-char hex string. Node's `createPublicKey`
|
|
61
|
+
* accepts a SubjectPublicKeyInfo DER blob — we wrap the raw key with the
|
|
62
|
+
* fixed Ed25519 OID prefix `302a300506032b6570032100`.
|
|
63
|
+
*/
|
|
64
|
+
function ed25519PublicKeyFromHex(hex: string) {
|
|
65
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
|
66
|
+
throw new Error(`expected 32-byte (64-char hex) ed25519 public key, got ${hex.length} chars`);
|
|
67
|
+
}
|
|
68
|
+
const der = Buffer.concat([
|
|
69
|
+
Buffer.from("302a300506032b6570032100", "hex"),
|
|
70
|
+
Buffer.from(hex, "hex"),
|
|
71
|
+
]);
|
|
72
|
+
return createPublicKey({ key: der, format: "der", type: "spki" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sign a body with an Ed25519 private key — used by smoke tests and the
|
|
77
|
+
* in-repo integration test to construct fixtures the daemon will accept.
|
|
78
|
+
* Production code should NEVER call this — Discord signs requests, not us.
|
|
79
|
+
*/
|
|
80
|
+
export function signDiscordBody(args: {
|
|
81
|
+
body: string;
|
|
82
|
+
timestamp: number | string;
|
|
83
|
+
privateKeyPem: string;
|
|
84
|
+
}): string {
|
|
85
|
+
const message = Buffer.from(`${args.timestamp}${args.body}`, "utf8");
|
|
86
|
+
const sk = createPrivateKey({ key: args.privateKeyPem });
|
|
87
|
+
const sig = cryptoSign(null, message, sk);
|
|
88
|
+
return sig.toString("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate a fresh Ed25519 keypair (PEM-encoded). Used by the test
|
|
93
|
+
* harness so we can sign + verify our own fixtures end-to-end.
|
|
94
|
+
*/
|
|
95
|
+
export function generateEd25519Keypair(): {
|
|
96
|
+
publicKeyHex: string;
|
|
97
|
+
privateKeyPem: string;
|
|
98
|
+
} {
|
|
99
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
100
|
+
// Extract raw 32-byte public key from the DER-encoded SPKI:
|
|
101
|
+
// SPKI = 30 2a 30 05 06 03 2b 65 70 03 21 00 <32-byte-key>
|
|
102
|
+
const spkiDer = publicKey.export({ format: "der", type: "spki" });
|
|
103
|
+
const rawKey = spkiDer.subarray(spkiDer.length - 32);
|
|
104
|
+
const publicKeyHex = Buffer.from(rawKey).toString("hex");
|
|
105
|
+
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
106
|
+
return { publicKeyHex, privateKeyPem };
|
|
107
|
+
}
|