@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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
export { generateEd25519Keypair, signDiscordBody, verifyDiscordSignature, } from "./verify.js";
|
|
21
|
+
export declare class DiscordAdapterError extends CrewhausError {
|
|
22
|
+
readonly name = "DiscordAdapterError";
|
|
23
|
+
constructor(message: string, cause?: unknown);
|
|
24
|
+
}
|
|
25
|
+
export type RawRequest = {
|
|
26
|
+
readonly headers: Headers;
|
|
27
|
+
readonly body: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Channel-generic inbound event. Same shape as Slack/Telegram:
|
|
31
|
+
*
|
|
32
|
+
* Mappings:
|
|
33
|
+
* - workspaceId → guild_id (or "dm" when not in a guild)
|
|
34
|
+
* - channelId → channel_id
|
|
35
|
+
* - userId → user.id (or member.user.id in guild context)
|
|
36
|
+
* - threadTs → channel_id when the channel is a thread
|
|
37
|
+
* - ts → interaction id (snowflake; monotonic-ish)
|
|
38
|
+
* - text → command-name + options for slash commands;
|
|
39
|
+
* custom_id for component clicks; modal field values
|
|
40
|
+
* joined for modal submits
|
|
41
|
+
* - subtype → "message" | "app_mention"
|
|
42
|
+
* - idempotencyKey → interaction id (Discord doesn't redeliver, but we
|
|
43
|
+
* surface the id so the gateway dedups consistently)
|
|
44
|
+
*/
|
|
45
|
+
export type InboundEvent = {
|
|
46
|
+
readonly idempotencyKey: string;
|
|
47
|
+
readonly workspaceId: string;
|
|
48
|
+
readonly channelId: string;
|
|
49
|
+
readonly userId: string;
|
|
50
|
+
readonly threadTs?: string;
|
|
51
|
+
readonly ts: string;
|
|
52
|
+
readonly text: string;
|
|
53
|
+
readonly subtype: "app_mention" | "message";
|
|
54
|
+
};
|
|
55
|
+
export type ParsedInbound = {
|
|
56
|
+
readonly kind: "event";
|
|
57
|
+
readonly event: InboundEvent;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Discord pings every interactions endpoint with a `type:1` PING that
|
|
61
|
+
* must be answered with `type:1` PONG before any real interactions
|
|
62
|
+
* arrive. The gateway responds with the canned PONG body.
|
|
63
|
+
*/
|
|
64
|
+
| {
|
|
65
|
+
readonly kind: "challenge";
|
|
66
|
+
readonly challenge: string;
|
|
67
|
+
} | {
|
|
68
|
+
readonly kind: "skip";
|
|
69
|
+
};
|
|
70
|
+
export interface ChannelAdapter {
|
|
71
|
+
readonly id: string;
|
|
72
|
+
verify(req: RawRequest): boolean;
|
|
73
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
74
|
+
sendReply(args: {
|
|
75
|
+
event: InboundEvent;
|
|
76
|
+
text: string;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
setTyping(args: {
|
|
79
|
+
event: InboundEvent;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
export type DiscordAdapterConfig = {
|
|
83
|
+
/** Application id (snowflake). Used by the follow-up webhook URL. */
|
|
84
|
+
readonly applicationId: string;
|
|
85
|
+
/** Bot user token — used to authorize follow-up REST calls. */
|
|
86
|
+
readonly botToken: string;
|
|
87
|
+
/** Public key (hex, 64 chars) for Ed25519 verification. */
|
|
88
|
+
readonly publicKeyHex: string;
|
|
89
|
+
};
|
|
90
|
+
export type DiscordAdapterOptions = {
|
|
91
|
+
readonly apiBaseUrl?: string;
|
|
92
|
+
readonly fetch?: typeof fetch;
|
|
93
|
+
/** Clock injection for the signature replay-window check (tests). */
|
|
94
|
+
readonly now?: () => number;
|
|
95
|
+
};
|
|
96
|
+
export declare function createDiscordAdapter(config: DiscordAdapterConfig, opts?: DiscordAdapterOptions): ChannelAdapter;
|
|
97
|
+
export type DiscordInteraction = {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
readonly application_id?: string;
|
|
100
|
+
readonly type: number;
|
|
101
|
+
readonly token?: string;
|
|
102
|
+
readonly channel_id?: string;
|
|
103
|
+
readonly guild_id?: string;
|
|
104
|
+
readonly user?: {
|
|
105
|
+
readonly id: string;
|
|
106
|
+
readonly username?: string;
|
|
107
|
+
};
|
|
108
|
+
readonly member?: {
|
|
109
|
+
readonly user?: {
|
|
110
|
+
readonly id: string;
|
|
111
|
+
readonly username?: string;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
readonly channel?: {
|
|
115
|
+
readonly id?: string;
|
|
116
|
+
readonly parent_id?: string;
|
|
117
|
+
};
|
|
118
|
+
readonly data?: {
|
|
119
|
+
readonly name?: string;
|
|
120
|
+
readonly custom_id?: string;
|
|
121
|
+
readonly options?: ReadonlyArray<{
|
|
122
|
+
readonly name: string;
|
|
123
|
+
readonly value?: unknown;
|
|
124
|
+
}>;
|
|
125
|
+
readonly components?: ReadonlyArray<{
|
|
126
|
+
readonly components?: ReadonlyArray<{
|
|
127
|
+
readonly custom_id?: string;
|
|
128
|
+
readonly value?: string;
|
|
129
|
+
}>;
|
|
130
|
+
}>;
|
|
131
|
+
};
|
|
132
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
export { generateEd25519Keypair, signDiscordBody, verifyDiscordSignature, } from "./verify.js";
|
|
22
|
+
export class DiscordAdapterError extends CrewhausError {
|
|
23
|
+
name = "DiscordAdapterError";
|
|
24
|
+
constructor(message, cause) {
|
|
25
|
+
super("channel", message, cause);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const DEFAULT_API_BASE_URL = "https://discord.com/api/v10";
|
|
29
|
+
const PONG_RESPONSE_BODY = JSON.stringify({ type: 1 });
|
|
30
|
+
export function createDiscordAdapter(config, opts = {}) {
|
|
31
|
+
const apiBaseUrl = opts.apiBaseUrl ?? process.env["DISCORD_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
32
|
+
const doFetch = opts.fetch ?? fetch;
|
|
33
|
+
return {
|
|
34
|
+
id: "discord",
|
|
35
|
+
verify(req) {
|
|
36
|
+
return verifyDiscordSignature({
|
|
37
|
+
headers: req.headers,
|
|
38
|
+
body: req.body,
|
|
39
|
+
publicKeyHex: config.publicKeyHex,
|
|
40
|
+
}, opts.now !== undefined ? { now: opts.now } : {});
|
|
41
|
+
},
|
|
42
|
+
parseInbound(req) {
|
|
43
|
+
let payload;
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(req.body);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { kind: "skip" };
|
|
49
|
+
}
|
|
50
|
+
if (typeof payload !== "object" || payload === null)
|
|
51
|
+
return { kind: "skip" };
|
|
52
|
+
const p = payload;
|
|
53
|
+
// PING → PONG handshake (Discord's URL-verification analogue).
|
|
54
|
+
if (p.type === 1) {
|
|
55
|
+
return { kind: "challenge", challenge: PONG_RESPONSE_BODY };
|
|
56
|
+
}
|
|
57
|
+
// We handle types 2 (slash command), 3 (component click), 5 (modal submit).
|
|
58
|
+
if (p.type !== 2 && p.type !== 3 && p.type !== 5) {
|
|
59
|
+
return { kind: "skip" };
|
|
60
|
+
}
|
|
61
|
+
const interactionId = p.id;
|
|
62
|
+
const channelId = p.channel_id;
|
|
63
|
+
const userId = p.member?.user?.id ?? p.user?.id;
|
|
64
|
+
if (!interactionId || !channelId || !userId)
|
|
65
|
+
return { kind: "skip" };
|
|
66
|
+
const workspaceId = p.guild_id ?? "dm";
|
|
67
|
+
// Threads: in Discord a "thread" is a Channel with `parent_id` set
|
|
68
|
+
// and type 11 (public thread) / 12 (private thread) / 10 (news
|
|
69
|
+
// thread). The interaction.channel object includes `parent_id` when
|
|
70
|
+
// the interaction is in a thread.
|
|
71
|
+
const isThread = typeof p.channel?.parent_id === "string" && p.channel.parent_id.length > 0;
|
|
72
|
+
const threadTs = isThread ? channelId : undefined;
|
|
73
|
+
const text = renderInteractionText(p);
|
|
74
|
+
const event = {
|
|
75
|
+
idempotencyKey: interactionId,
|
|
76
|
+
workspaceId,
|
|
77
|
+
channelId,
|
|
78
|
+
userId,
|
|
79
|
+
...(threadTs !== undefined ? { threadTs } : {}),
|
|
80
|
+
ts: interactionId,
|
|
81
|
+
text,
|
|
82
|
+
subtype: "message",
|
|
83
|
+
};
|
|
84
|
+
return { kind: "event", event };
|
|
85
|
+
},
|
|
86
|
+
async sendReply(args) {
|
|
87
|
+
// Use Discord's interaction follow-up endpoint:
|
|
88
|
+
// POST /webhooks/{application.id}/{interaction.token}
|
|
89
|
+
// The interaction.token isn't part of `InboundEvent`; in practice the
|
|
90
|
+
// gateway should call `interaction.respond({type:5, data: {...}})`
|
|
91
|
+
// synchronously to defer, then we follow up via this REST call. For
|
|
92
|
+
// the v0 channel-bot daemon we use the application's webhook channel
|
|
93
|
+
// POST as a fallback so the message reaches the channel even without
|
|
94
|
+
// the interaction token (slightly different rendering — no
|
|
95
|
+
// ephemeral, no per-user replies).
|
|
96
|
+
const url = `${apiBaseUrl}/channels/${args.event.channelId}/messages`;
|
|
97
|
+
const res = await doFetch(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
Authorization: `Bot ${config.botToken}`,
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({ content: args.text }),
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new DiscordAdapterError(`channels/${args.event.channelId}/messages failed: ${res.status} ${res.statusText}`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
// Note: no `react()` hook. Discord's inbound is an *interaction* (slash
|
|
110
|
+
// command / component / modal), not a channel message, and `event.ts` is
|
|
111
|
+
// the interaction id — there is no user message to attach a reaction to.
|
|
112
|
+
// The session-router skips the (undefined) react hook silently, so status
|
|
113
|
+
// acks simply don't appear on Discord. (Slack/Telegram/WhatsApp implement
|
|
114
|
+
// react(); iMessage cannot.)
|
|
115
|
+
async setTyping(args) {
|
|
116
|
+
const url = `${apiBaseUrl}/channels/${args.event.channelId}/typing`;
|
|
117
|
+
// Best-effort — do not surface failures. A failed typing indicator must
|
|
118
|
+
// never reject and break the caller's reply flow, so we swallow both
|
|
119
|
+
// network rejections (DNS/abort/connection) and non-2xx responses.
|
|
120
|
+
try {
|
|
121
|
+
await doFetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: {
|
|
124
|
+
Authorization: `Bot ${config.botToken}`,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// ignore — typing indicator is non-essential.
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Reduce an interaction payload to a single text body for the agent. */
|
|
135
|
+
function renderInteractionText(p) {
|
|
136
|
+
if (p.type === 2) {
|
|
137
|
+
// Slash command: render as `/<name> <option1=value1> <option2=value2>`
|
|
138
|
+
const cmd = p.data?.name ?? "?";
|
|
139
|
+
const opts = (p.data?.options ?? [])
|
|
140
|
+
.map((o) => `${o.name}=${stringifyOption(o.value)}`)
|
|
141
|
+
.join(" ");
|
|
142
|
+
return opts ? `/${cmd} ${opts}` : `/${cmd}`;
|
|
143
|
+
}
|
|
144
|
+
if (p.type === 3) {
|
|
145
|
+
// Component click: `[component:<custom_id>]`
|
|
146
|
+
return `[component:${p.data?.custom_id ?? ""}]`;
|
|
147
|
+
}
|
|
148
|
+
if (p.type === 5) {
|
|
149
|
+
// Modal submit: render as `<custom_id>: field1=value1 field2=value2`
|
|
150
|
+
const id = p.data?.custom_id ?? "";
|
|
151
|
+
const fields = (p.data?.components ?? [])
|
|
152
|
+
.flatMap((row) => row.components ?? [])
|
|
153
|
+
.map((c) => `${c.custom_id ?? "?"}=${c.value ?? ""}`)
|
|
154
|
+
.join(" ");
|
|
155
|
+
return `${id}: ${fields}`;
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
function stringifyOption(v) {
|
|
160
|
+
if (v === null || v === undefined)
|
|
161
|
+
return "";
|
|
162
|
+
if (typeof v === "string")
|
|
163
|
+
return v;
|
|
164
|
+
return JSON.stringify(v);
|
|
165
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord interaction signature verification per
|
|
3
|
+
* https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization.
|
|
4
|
+
*
|
|
5
|
+
* Discord signs interaction webhooks with Ed25519. Each request carries
|
|
6
|
+
* `X-Signature-Ed25519` (hex) and `X-Signature-Timestamp` (seconds). The
|
|
7
|
+
* signed payload is `<timestamp><body>`. The bot's `publicKey` (hex,
|
|
8
|
+
* 64 chars) is the verification key.
|
|
9
|
+
*
|
|
10
|
+
* We use Node's built-in `crypto.verify` with `null` algorithm and an
|
|
11
|
+
* Ed25519 KeyObject built from the raw 32-byte public key — no external
|
|
12
|
+
* SDK needed.
|
|
13
|
+
*/
|
|
14
|
+
export type DiscordVerifyArgs = {
|
|
15
|
+
readonly headers: Headers;
|
|
16
|
+
readonly body: string;
|
|
17
|
+
readonly publicKeyHex: string;
|
|
18
|
+
};
|
|
19
|
+
export type DiscordVerifyOptions = {
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
readonly toleranceMs?: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function verifyDiscordSignature(args: DiscordVerifyArgs, opts?: DiscordVerifyOptions): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Sign a body with an Ed25519 private key — used by smoke tests and the
|
|
26
|
+
* in-repo integration test to construct fixtures the daemon will accept.
|
|
27
|
+
* Production code should NEVER call this — Discord signs requests, not us.
|
|
28
|
+
*/
|
|
29
|
+
export declare function signDiscordBody(args: {
|
|
30
|
+
body: string;
|
|
31
|
+
timestamp: number | string;
|
|
32
|
+
privateKeyPem: string;
|
|
33
|
+
}): string;
|
|
34
|
+
/**
|
|
35
|
+
* Generate a fresh Ed25519 keypair (PEM-encoded). Used by the test
|
|
36
|
+
* harness so we can sign + verify our own fixtures end-to-end.
|
|
37
|
+
*/
|
|
38
|
+
export declare function generateEd25519Keypair(): {
|
|
39
|
+
publicKeyHex: string;
|
|
40
|
+
privateKeyPem: string;
|
|
41
|
+
};
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, sign as cryptoSign, generateKeyPairSync, verify, } from "node:crypto";
|
|
2
|
+
// Discord recommends rejecting interactions whose timestamp is too far from
|
|
3
|
+
// now; 5 minutes matches the Slack adapter's replay-window cap.
|
|
4
|
+
const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
|
|
5
|
+
export function verifyDiscordSignature(args, opts = {}) {
|
|
6
|
+
const now = opts.now ?? (() => Date.now());
|
|
7
|
+
const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
|
|
8
|
+
const sigHex = args.headers.get("x-signature-ed25519");
|
|
9
|
+
const timestamp = args.headers.get("x-signature-timestamp");
|
|
10
|
+
if (!sigHex || !timestamp)
|
|
11
|
+
return false;
|
|
12
|
+
if (!/^[0-9a-f]{128}$/i.test(sigHex))
|
|
13
|
+
return false;
|
|
14
|
+
if (!/^\d+$/.test(timestamp))
|
|
15
|
+
return false;
|
|
16
|
+
// Replay-window: the timestamp is folded into the signed payload, so a
|
|
17
|
+
// captured (timestamp, body, signature) triple verifies indefinitely
|
|
18
|
+
// without this freshness bound. Reject stale/future timestamps (seconds
|
|
19
|
+
// since epoch), mirroring the Slack adapter.
|
|
20
|
+
const tsNum = Number.parseInt(timestamp, 10);
|
|
21
|
+
if (!Number.isFinite(tsNum))
|
|
22
|
+
return false;
|
|
23
|
+
if (Math.abs(now() - tsNum * 1000) > tolerance)
|
|
24
|
+
return false;
|
|
25
|
+
// `sigHex` is regex-validated above to be exactly 128 hex chars, so
|
|
26
|
+
// `Buffer.from(_, "hex")` always yields exactly 64 bytes and never throws
|
|
27
|
+
// (`Buffer.from` silently drops invalid hex rather than throwing). The
|
|
28
|
+
// length re-check below is a cheap, defensive belt for that invariant.
|
|
29
|
+
const signature = Buffer.from(sigHex, "hex");
|
|
30
|
+
if (signature.length !== 64)
|
|
31
|
+
return false;
|
|
32
|
+
let publicKey;
|
|
33
|
+
try {
|
|
34
|
+
publicKey = ed25519PublicKeyFromHex(args.publicKeyHex);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const message = Buffer.from(`${timestamp}${args.body}`, "utf8");
|
|
40
|
+
try {
|
|
41
|
+
return verify(null, message, publicKey, signature);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Construct an Ed25519 public KeyObject from a 32-byte hex string. Discord's
|
|
49
|
+
* docs publish the bot key as a 64-char hex string. Node's `createPublicKey`
|
|
50
|
+
* accepts a SubjectPublicKeyInfo DER blob — we wrap the raw key with the
|
|
51
|
+
* fixed Ed25519 OID prefix `302a300506032b6570032100`.
|
|
52
|
+
*/
|
|
53
|
+
function ed25519PublicKeyFromHex(hex) {
|
|
54
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
|
55
|
+
throw new Error(`expected 32-byte (64-char hex) ed25519 public key, got ${hex.length} chars`);
|
|
56
|
+
}
|
|
57
|
+
const der = Buffer.concat([
|
|
58
|
+
Buffer.from("302a300506032b6570032100", "hex"),
|
|
59
|
+
Buffer.from(hex, "hex"),
|
|
60
|
+
]);
|
|
61
|
+
return createPublicKey({ key: der, format: "der", type: "spki" });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sign a body with an Ed25519 private key — used by smoke tests and the
|
|
65
|
+
* in-repo integration test to construct fixtures the daemon will accept.
|
|
66
|
+
* Production code should NEVER call this — Discord signs requests, not us.
|
|
67
|
+
*/
|
|
68
|
+
export function signDiscordBody(args) {
|
|
69
|
+
const message = Buffer.from(`${args.timestamp}${args.body}`, "utf8");
|
|
70
|
+
const sk = createPrivateKey({ key: args.privateKeyPem });
|
|
71
|
+
const sig = cryptoSign(null, message, sk);
|
|
72
|
+
return sig.toString("hex");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generate a fresh Ed25519 keypair (PEM-encoded). Used by the test
|
|
76
|
+
* harness so we can sign + verify our own fixtures end-to-end.
|
|
77
|
+
*/
|
|
78
|
+
export function generateEd25519Keypair() {
|
|
79
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
80
|
+
// Extract raw 32-byte public key from the DER-encoded SPKI:
|
|
81
|
+
// SPKI = 30 2a 30 05 06 03 2b 65 70 03 21 00 <32-byte-key>
|
|
82
|
+
const spkiDer = publicKey.export({ format: "der", type: "spki" });
|
|
83
|
+
const rawKey = spkiDer.subarray(spkiDer.length - 32);
|
|
84
|
+
const publicKeyHex = Buffer.from(rawKey).toString("hex");
|
|
85
|
+
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
86
|
+
return { publicKeyHex, privateKeyPem };
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-discord",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Discord channel adapter: Ed25519 signature verification + slash-command/button/modal interaction parsing + interaction-response API (Section 33)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
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
|
-
}
|
package/src/fixtures/ping.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|