@crewhaus/channel-adapter-discord 0.1.3 → 0.1.5
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/dist/index.d.ts +132 -0
- package/dist/index.js +165 -0
- package/dist/verify.d.ts +41 -0
- package/dist/verify.js +87 -0
- package/package.json +9 -6
- package/src/fixtures/component_button.json +0 -13
- package/src/fixtures/missing_user.json +0 -9
- package/src/fixtures/modal_submit.json +0 -22
- package/src/fixtures/ping.json +0 -6
- package/src/fixtures/slash_command_basic.json +0 -13
- package/src/fixtures/slash_command_dm.json +0 -12
- package/src/fixtures/slash_command_thread.json +0 -14
- package/src/fixtures/slash_command_with_options.json +0 -16
- package/src/fixtures/unknown_type.json +0 -7
- package/src/index.test.ts +0 -343
- package/src/index.ts +0 -272
- package/src/verify.crypto-throw.test.ts +0 -61
- package/src/verify.ts +0 -129
package/src/index.test.ts
DELETED
|
@@ -1,343 +0,0 @@
|
|
|
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 = Math.floor(Date.now() / 1000)): 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
|
-
// SECURITY: the timestamp is signed but was never checked against the clock,
|
|
121
|
-
// so a captured interaction replayed indefinitely (after a daemon restart or
|
|
122
|
-
// LRU dedup eviction). Reject stale/future timestamps, mirroring Slack.
|
|
123
|
-
test("rejects an expired timestamp (replay window)", () => {
|
|
124
|
-
const body = fixture("ping");
|
|
125
|
-
const oldTs = Math.floor(Date.now() / 1000) - 10 * 60; // 10 minutes ago
|
|
126
|
-
expect(
|
|
127
|
-
verifyDiscordSignature({ headers: signedHeaders(body, oldTs), body, publicKeyHex }),
|
|
128
|
-
).toBe(false);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("rejects a future timestamp", () => {
|
|
132
|
-
const body = fixture("ping");
|
|
133
|
-
const futureTs = Math.floor(Date.now() / 1000) + 10 * 60;
|
|
134
|
-
expect(
|
|
135
|
-
verifyDiscordSignature({ headers: signedHeaders(body, futureTs), body, publicKeyHex }),
|
|
136
|
-
).toBe(false);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("a stale-but-validly-signed triple verifies when now() is pinned (gate is freshness, not signature)", () => {
|
|
140
|
-
const body = fixture("ping");
|
|
141
|
-
const ts = 1700000000;
|
|
142
|
-
const headers = signedHeaders(body, ts);
|
|
143
|
-
expect(verifyDiscordSignature({ headers, body, publicKeyHex }, { now: () => ts * 1000 })).toBe(
|
|
144
|
-
true,
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("rejects malformed publicKeyHex", () => {
|
|
149
|
-
const body = fixture("ping");
|
|
150
|
-
expect(
|
|
151
|
-
verifyDiscordSignature({
|
|
152
|
-
headers: signedHeaders(body),
|
|
153
|
-
body,
|
|
154
|
-
publicKeyHex: "not-hex",
|
|
155
|
-
}),
|
|
156
|
-
).toBe(false);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
describe("parseInbound — fixtures (T2)", () => {
|
|
161
|
-
test("ping → challenge with PONG body", () => {
|
|
162
|
-
const a = adapter();
|
|
163
|
-
const body = fixture("ping");
|
|
164
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
165
|
-
expect(r.kind).toBe("challenge");
|
|
166
|
-
if (r.kind === "challenge") {
|
|
167
|
-
expect(JSON.parse(r.challenge)).toEqual({ type: 1 });
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("slash_command_basic → event with text /<name>", () => {
|
|
172
|
-
const a = adapter();
|
|
173
|
-
const body = fixture("slash_command_basic");
|
|
174
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
175
|
-
ParsedInbound,
|
|
176
|
-
{ kind: "event" }
|
|
177
|
-
>;
|
|
178
|
-
expect(r.kind).toBe("event");
|
|
179
|
-
expect(r.event.text).toBe("/ping");
|
|
180
|
-
expect(r.event.channelId).toBe("300000000000000001");
|
|
181
|
-
expect(r.event.userId).toBe("500000000000000001");
|
|
182
|
-
expect(r.event.workspaceId).toBe("400000000000000001");
|
|
183
|
-
expect(r.event.threadTs).toBeUndefined();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("slash_command_with_options → text encodes name=value pairs", () => {
|
|
187
|
-
const a = adapter();
|
|
188
|
-
const body = fixture("slash_command_with_options");
|
|
189
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
190
|
-
ParsedInbound,
|
|
191
|
-
{ kind: "event" }
|
|
192
|
-
>;
|
|
193
|
-
expect(r.event.text).toBe("/summarize url=https://example.com depth=3");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("slash_command_thread → channelId is thread id, threadTs set", () => {
|
|
197
|
-
const a = adapter();
|
|
198
|
-
const body = fixture("slash_command_thread");
|
|
199
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
200
|
-
ParsedInbound,
|
|
201
|
-
{ kind: "event" }
|
|
202
|
-
>;
|
|
203
|
-
expect(r.event.channelId).toBe("300000000000000099");
|
|
204
|
-
expect(r.event.threadTs).toBe("300000000000000099");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test('slash_command_dm → workspaceId is "dm", user.id used', () => {
|
|
208
|
-
const a = adapter();
|
|
209
|
-
const body = fixture("slash_command_dm");
|
|
210
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
211
|
-
ParsedInbound,
|
|
212
|
-
{ kind: "event" }
|
|
213
|
-
>;
|
|
214
|
-
expect(r.event.workspaceId).toBe("dm");
|
|
215
|
-
expect(r.event.userId).toBe("500000000000000007");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("component_button → text [component:<custom_id>]", () => {
|
|
219
|
-
const a = adapter();
|
|
220
|
-
const body = fixture("component_button");
|
|
221
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
222
|
-
ParsedInbound,
|
|
223
|
-
{ kind: "event" }
|
|
224
|
-
>;
|
|
225
|
-
expect(r.event.text).toBe("[component:approve:doc-123]");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("modal_submit → text encodes form fields", () => {
|
|
229
|
-
const a = adapter();
|
|
230
|
-
const body = fixture("modal_submit");
|
|
231
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
232
|
-
ParsedInbound,
|
|
233
|
-
{ kind: "event" }
|
|
234
|
-
>;
|
|
235
|
-
expect(r.event.text).toBe("feedback-form: rating=5 comment=loved it");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("unknown_type → skip", () => {
|
|
239
|
-
const a = adapter();
|
|
240
|
-
const body = fixture("unknown_type");
|
|
241
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
242
|
-
expect(r.kind).toBe("skip");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("missing_user → skip", () => {
|
|
246
|
-
const a = adapter();
|
|
247
|
-
const body = fixture("missing_user");
|
|
248
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body });
|
|
249
|
-
expect(r.kind).toBe("skip");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("malformed JSON → skip", () => {
|
|
253
|
-
const a = adapter();
|
|
254
|
-
const r = a.parseInbound({ headers: new Headers(), body: "not json{" });
|
|
255
|
-
expect(r.kind).toBe("skip");
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("idempotencyKey == interaction id", () => {
|
|
259
|
-
const a = adapter();
|
|
260
|
-
const body = fixture("slash_command_basic");
|
|
261
|
-
const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
|
|
262
|
-
ParsedInbound,
|
|
263
|
-
{ kind: "event" }
|
|
264
|
-
>;
|
|
265
|
-
expect(r.event.idempotencyKey).toBe("100000000000000002");
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe("sendReply / setTyping (T3)", () => {
|
|
270
|
-
function captureFetch() {
|
|
271
|
-
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
272
|
-
const f = (async (input: string | Request | URL, init?: RequestInit) => {
|
|
273
|
-
calls.push({ url: String(input), init: init ?? {} });
|
|
274
|
-
return new Response("", { status: 204 });
|
|
275
|
-
}) as unknown as typeof fetch;
|
|
276
|
-
return { calls, fetch: f };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const event: InboundEvent = {
|
|
280
|
-
idempotencyKey: "100000000000000002",
|
|
281
|
-
workspaceId: "400000000000000001",
|
|
282
|
-
channelId: "300000000000000001",
|
|
283
|
-
userId: "500000000000000001",
|
|
284
|
-
ts: "100000000000000002",
|
|
285
|
-
text: "/ping",
|
|
286
|
-
subtype: "message",
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
test("sendReply POSTs /channels/{channelId}/messages with content", async () => {
|
|
290
|
-
const { calls, fetch: f } = captureFetch();
|
|
291
|
-
const a = adapter({ fetch: f });
|
|
292
|
-
await a.sendReply({ event, text: "pong" });
|
|
293
|
-
expect(calls.length).toBe(1);
|
|
294
|
-
expect(calls[0]?.url).toBe("https://test.discord.local/channels/300000000000000001/messages");
|
|
295
|
-
const body = JSON.parse(String(calls[0]?.init.body));
|
|
296
|
-
expect(body.content).toBe("pong");
|
|
297
|
-
const auth = (calls[0]?.init.headers as Record<string, string> | undefined)?.["Authorization"];
|
|
298
|
-
expect(auth).toBe("Bot Bot.token");
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test("sendReply throws DiscordAdapterError on HTTP error", async () => {
|
|
302
|
-
const f = (async () =>
|
|
303
|
-
new Response("server fail", {
|
|
304
|
-
status: 500,
|
|
305
|
-
statusText: "Internal",
|
|
306
|
-
})) as unknown as typeof fetch;
|
|
307
|
-
const a = adapter({ fetch: f });
|
|
308
|
-
await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(DiscordAdapterError);
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
test("setTyping POSTs /channels/{channelId}/typing", async () => {
|
|
312
|
-
const { calls, fetch: f } = captureFetch();
|
|
313
|
-
const a = adapter({ fetch: f });
|
|
314
|
-
await a.setTyping({ event });
|
|
315
|
-
expect(calls.length).toBe(1);
|
|
316
|
-
expect(calls[0]?.url).toBe("https://test.discord.local/channels/300000000000000001/typing");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
test("setTyping swallows network rejection (best-effort)", async () => {
|
|
320
|
-
// A failed typing indicator must never reject and break the reply flow.
|
|
321
|
-
const f = (async () => {
|
|
322
|
-
throw new Error("network down");
|
|
323
|
-
}) as unknown as typeof fetch;
|
|
324
|
-
const a = adapter({ fetch: f });
|
|
325
|
-
await expect(a.setTyping({ event })).resolves.toBeUndefined();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("setTyping ignores non-2xx responses (best-effort)", async () => {
|
|
329
|
-
const f = (async () =>
|
|
330
|
-
new Response("nope", { status: 500, statusText: "Internal" })) as unknown as typeof fetch;
|
|
331
|
-
const a = adapter({ fetch: f });
|
|
332
|
-
await expect(a.setTyping({ event })).resolves.toBeUndefined();
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
describe("createDiscordAdapter.verify()", () => {
|
|
337
|
-
test("forwards to verifyDiscordSignature", () => {
|
|
338
|
-
const a = adapter();
|
|
339
|
-
const body = fixture("ping");
|
|
340
|
-
expect(a.verify({ headers: signedHeaders(body), body })).toBe(true);
|
|
341
|
-
expect(a.verify({ headers: new Headers(), body })).toBe(false);
|
|
342
|
-
});
|
|
343
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
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
|
-
/** Clock injection for the signature replay-window check (tests). */
|
|
98
|
-
readonly now?: () => number;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
|
|
102
|
-
const PONG_RESPONSE_BODY = JSON.stringify({ type: 1 });
|
|
103
|
-
|
|
104
|
-
export function createDiscordAdapter(
|
|
105
|
-
config: DiscordAdapterConfig,
|
|
106
|
-
opts: DiscordAdapterOptions = {},
|
|
107
|
-
): ChannelAdapter {
|
|
108
|
-
const apiBaseUrl = opts.apiBaseUrl ?? process.env["DISCORD_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
109
|
-
const doFetch = opts.fetch ?? fetch;
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
id: "discord",
|
|
113
|
-
|
|
114
|
-
verify(req: RawRequest): boolean {
|
|
115
|
-
return verifyDiscordSignature(
|
|
116
|
-
{
|
|
117
|
-
headers: req.headers,
|
|
118
|
-
body: req.body,
|
|
119
|
-
publicKeyHex: config.publicKeyHex,
|
|
120
|
-
},
|
|
121
|
-
opts.now !== undefined ? { now: opts.now } : {},
|
|
122
|
-
);
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
parseInbound(req: RawRequest): ParsedInbound {
|
|
126
|
-
let payload: unknown;
|
|
127
|
-
try {
|
|
128
|
-
payload = JSON.parse(req.body);
|
|
129
|
-
} catch {
|
|
130
|
-
return { kind: "skip" };
|
|
131
|
-
}
|
|
132
|
-
if (typeof payload !== "object" || payload === null) return { kind: "skip" };
|
|
133
|
-
const p = payload as DiscordInteraction;
|
|
134
|
-
|
|
135
|
-
// PING → PONG handshake (Discord's URL-verification analogue).
|
|
136
|
-
if (p.type === 1) {
|
|
137
|
-
return { kind: "challenge", challenge: PONG_RESPONSE_BODY };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// We handle types 2 (slash command), 3 (component click), 5 (modal submit).
|
|
141
|
-
if (p.type !== 2 && p.type !== 3 && p.type !== 5) {
|
|
142
|
-
return { kind: "skip" };
|
|
143
|
-
}
|
|
144
|
-
const interactionId = p.id;
|
|
145
|
-
const channelId = p.channel_id;
|
|
146
|
-
const userId = p.member?.user?.id ?? p.user?.id;
|
|
147
|
-
if (!interactionId || !channelId || !userId) return { kind: "skip" };
|
|
148
|
-
|
|
149
|
-
const workspaceId = p.guild_id ?? "dm";
|
|
150
|
-
// Threads: in Discord a "thread" is a Channel with `parent_id` set
|
|
151
|
-
// and type 11 (public thread) / 12 (private thread) / 10 (news
|
|
152
|
-
// thread). The interaction.channel object includes `parent_id` when
|
|
153
|
-
// the interaction is in a thread.
|
|
154
|
-
const isThread = typeof p.channel?.parent_id === "string" && p.channel.parent_id.length > 0;
|
|
155
|
-
const threadTs = isThread ? channelId : undefined;
|
|
156
|
-
|
|
157
|
-
const text = renderInteractionText(p);
|
|
158
|
-
|
|
159
|
-
const event: InboundEvent = {
|
|
160
|
-
idempotencyKey: interactionId,
|
|
161
|
-
workspaceId,
|
|
162
|
-
channelId,
|
|
163
|
-
userId,
|
|
164
|
-
...(threadTs !== undefined ? { threadTs } : {}),
|
|
165
|
-
ts: interactionId,
|
|
166
|
-
text,
|
|
167
|
-
subtype: "message",
|
|
168
|
-
};
|
|
169
|
-
return { kind: "event", event };
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
|
|
173
|
-
// Use Discord's interaction follow-up endpoint:
|
|
174
|
-
// POST /webhooks/{application.id}/{interaction.token}
|
|
175
|
-
// The interaction.token isn't part of `InboundEvent`; in practice the
|
|
176
|
-
// gateway should call `interaction.respond({type:5, data: {...}})`
|
|
177
|
-
// synchronously to defer, then we follow up via this REST call. For
|
|
178
|
-
// the v0 channel-bot daemon we use the application's webhook channel
|
|
179
|
-
// POST as a fallback so the message reaches the channel even without
|
|
180
|
-
// the interaction token (slightly different rendering — no
|
|
181
|
-
// ephemeral, no per-user replies).
|
|
182
|
-
const url = `${apiBaseUrl}/channels/${args.event.channelId}/messages`;
|
|
183
|
-
const res = await doFetch(url, {
|
|
184
|
-
method: "POST",
|
|
185
|
-
headers: {
|
|
186
|
-
"Content-Type": "application/json",
|
|
187
|
-
Authorization: `Bot ${config.botToken}`,
|
|
188
|
-
},
|
|
189
|
-
body: JSON.stringify({ content: args.text }),
|
|
190
|
-
});
|
|
191
|
-
if (!res.ok) {
|
|
192
|
-
throw new DiscordAdapterError(
|
|
193
|
-
`channels/${args.event.channelId}/messages failed: ${res.status} ${res.statusText}`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
async setTyping(args: { event: InboundEvent }): Promise<void> {
|
|
199
|
-
const url = `${apiBaseUrl}/channels/${args.event.channelId}/typing`;
|
|
200
|
-
// Best-effort — do not surface failures. A failed typing indicator must
|
|
201
|
-
// never reject and break the caller's reply flow, so we swallow both
|
|
202
|
-
// network rejections (DNS/abort/connection) and non-2xx responses.
|
|
203
|
-
try {
|
|
204
|
-
await doFetch(url, {
|
|
205
|
-
method: "POST",
|
|
206
|
-
headers: {
|
|
207
|
-
Authorization: `Bot ${config.botToken}`,
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
} catch {
|
|
211
|
-
// ignore — typing indicator is non-essential.
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Reduce an interaction payload to a single text body for the agent. */
|
|
218
|
-
function renderInteractionText(p: DiscordInteraction): string {
|
|
219
|
-
if (p.type === 2) {
|
|
220
|
-
// Slash command: render as `/<name> <option1=value1> <option2=value2>`
|
|
221
|
-
const cmd = p.data?.name ?? "?";
|
|
222
|
-
const opts = (p.data?.options ?? [])
|
|
223
|
-
.map((o) => `${o.name}=${stringifyOption(o.value)}`)
|
|
224
|
-
.join(" ");
|
|
225
|
-
return opts ? `/${cmd} ${opts}` : `/${cmd}`;
|
|
226
|
-
}
|
|
227
|
-
if (p.type === 3) {
|
|
228
|
-
// Component click: `[component:<custom_id>]`
|
|
229
|
-
return `[component:${p.data?.custom_id ?? ""}]`;
|
|
230
|
-
}
|
|
231
|
-
if (p.type === 5) {
|
|
232
|
-
// Modal submit: render as `<custom_id>: field1=value1 field2=value2`
|
|
233
|
-
const id = p.data?.custom_id ?? "";
|
|
234
|
-
const fields = (p.data?.components ?? [])
|
|
235
|
-
.flatMap((row) => row.components ?? [])
|
|
236
|
-
.map((c) => `${c.custom_id ?? "?"}=${c.value ?? ""}`)
|
|
237
|
-
.join(" ");
|
|
238
|
-
return `${id}: ${fields}`;
|
|
239
|
-
}
|
|
240
|
-
return "";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function stringifyOption(v: unknown): string {
|
|
244
|
-
if (v === null || v === undefined) return "";
|
|
245
|
-
if (typeof v === "string") return v;
|
|
246
|
-
return JSON.stringify(v);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ─── Minimal Discord interaction shape (no SDK) ──────────────────────────────
|
|
250
|
-
|
|
251
|
-
export type DiscordInteraction = {
|
|
252
|
-
readonly id: string;
|
|
253
|
-
readonly application_id?: string;
|
|
254
|
-
readonly type: number;
|
|
255
|
-
readonly token?: string;
|
|
256
|
-
readonly channel_id?: string;
|
|
257
|
-
readonly guild_id?: string;
|
|
258
|
-
readonly user?: { readonly id: string; readonly username?: string };
|
|
259
|
-
readonly member?: { readonly user?: { readonly id: string; readonly username?: string } };
|
|
260
|
-
readonly channel?: { readonly id?: string; readonly parent_id?: string };
|
|
261
|
-
readonly data?: {
|
|
262
|
-
readonly name?: string;
|
|
263
|
-
readonly custom_id?: string;
|
|
264
|
-
readonly options?: ReadonlyArray<{ readonly name: string; readonly value?: unknown }>;
|
|
265
|
-
readonly components?: ReadonlyArray<{
|
|
266
|
-
readonly components?: ReadonlyArray<{
|
|
267
|
-
readonly custom_id?: string;
|
|
268
|
-
readonly value?: string;
|
|
269
|
-
}>;
|
|
270
|
-
}>;
|
|
271
|
-
};
|
|
272
|
-
};
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated test for the defensive `catch` around Node's `crypto.verify` in
|
|
3
|
-
* `verifyDiscordSignature`.
|
|
4
|
-
*
|
|
5
|
-
* With a valid Ed25519 key and a 64-byte signature, OpenSSL's `verify` returns
|
|
6
|
-
* `false` rather than throwing, so the catch is unreachable through ordinary
|
|
7
|
-
* inputs. It is still legitimate defensive code: `crypto.verify(null, …)` can
|
|
8
|
-
* throw `ERR_OSSL_*` for unexpected key/state combinations. We exercise it by
|
|
9
|
-
* stubbing `node:crypto.verify` to throw and asserting the function degrades to
|
|
10
|
-
* `false` instead of propagating.
|
|
11
|
-
*
|
|
12
|
-
* `mock.module` mutates the module registry for the REST OF THE PROCESS —
|
|
13
|
-
* `bun test` runs every test file in one process and does NOT give each file
|
|
14
|
-
* a fresh module graph, and file execution order is nondeterministic. If this
|
|
15
|
-
* file runs before `index.test.ts` without the `afterAll` restore below, the
|
|
16
|
-
* throwing stub leaks into it and every valid-signature test fails (observed
|
|
17
|
-
* intermittently in CI). The restore is load-bearing; keep it.
|
|
18
|
-
*/
|
|
19
|
-
import { afterAll, expect, mock, test } from "bun:test";
|
|
20
|
-
|
|
21
|
-
const realCrypto = require("node:crypto") as typeof import("node:crypto");
|
|
22
|
-
|
|
23
|
-
mock.module("node:crypto", () => ({
|
|
24
|
-
...realCrypto,
|
|
25
|
-
// Force the verification path to throw the way OpenSSL can under
|
|
26
|
-
// unexpected conditions (e.g. ERR_OSSL_NO_DEFAULT_DIGEST).
|
|
27
|
-
verify: () => {
|
|
28
|
-
throw new Error("ERR_OSSL synthetic failure");
|
|
29
|
-
},
|
|
30
|
-
}));
|
|
31
|
-
|
|
32
|
-
afterAll(() => {
|
|
33
|
-
// Bun has no mock.module restore API; re-mocking with the real exports is
|
|
34
|
-
// the documented way to undo a module mock. This updates live bindings in
|
|
35
|
-
// already-loaded importers (verify.ts), so files that run after this one
|
|
36
|
-
// see the real `crypto.verify` again.
|
|
37
|
-
mock.module("node:crypto", () => realCrypto);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Import the module under test *after* the stub is registered so its
|
|
41
|
-
// `import { verify } from "node:crypto"` binding resolves to the stub.
|
|
42
|
-
const { verifyDiscordSignature, generateEd25519Keypair, signDiscordBody } = await import(
|
|
43
|
-
"./verify.js"
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
test("verifyDiscordSignature returns false when crypto.verify throws", () => {
|
|
47
|
-
// Keypair generation + signing still use the real crypto (spread above).
|
|
48
|
-
const { publicKeyHex, privateKeyPem } = generateEd25519Keypair();
|
|
49
|
-
const body = JSON.stringify({ type: 1 });
|
|
50
|
-
const timestamp = "1700000000";
|
|
51
|
-
const sig = signDiscordBody({ body, timestamp, privateKeyPem });
|
|
52
|
-
|
|
53
|
-
const headers = new Headers();
|
|
54
|
-
headers.set("X-Signature-Ed25519", sig);
|
|
55
|
-
headers.set("X-Signature-Timestamp", timestamp);
|
|
56
|
-
|
|
57
|
-
// Every guard (hex regex, timestamp regex, 64-byte length, public-key
|
|
58
|
-
// construction) passes, so control reaches the `verify(...)` call — which
|
|
59
|
-
// now throws — and the catch must convert it to a clean `false`.
|
|
60
|
-
expect(verifyDiscordSignature({ headers, body, publicKeyHex })).toBe(false);
|
|
61
|
-
});
|