@hogsend/plugin-discord 0.22.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/LICENSE +93 -0
- package/README.md +235 -0
- package/package.json +50 -0
- package/src/__tests__/connector-transform.test.ts +178 -0
- package/src/__tests__/gateway-forward.test.ts +94 -0
- package/src/__tests__/interactions-followup.test.ts +94 -0
- package/src/__tests__/interactions.test.ts +585 -0
- package/src/__tests__/member-link.test.ts +58 -0
- package/src/__tests__/oauth.test.ts +214 -0
- package/src/connect/interactions-followup.ts +59 -0
- package/src/connect/interactions.ts +864 -0
- package/src/connect/member-link.ts +79 -0
- package/src/connect/oauth.ts +159 -0
- package/src/connect/patch-application.ts +67 -0
- package/src/connector.ts +541 -0
- package/src/constants.ts +47 -0
- package/src/destination.ts +95 -0
- package/src/env.ts +25 -0
- package/src/events.ts +16 -0
- package/src/gateway/index.ts +7 -0
- package/src/gateway/ingress.ts +50 -0
- package/src/gateway/worker.ts +180 -0
- package/src/index.ts +71 -0
- package/src/types.ts +61 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { createPublicKey, verify as edVerify } from "node:crypto";
|
|
2
|
+
import { LINK_CODE_TTL_SECONDS } from "@hogsend/engine";
|
|
3
|
+
import { editInteractionResponse } from "./interactions-followup.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Discord interactions — Ed25519 request verification + the native identify UX.
|
|
7
|
+
*
|
|
8
|
+
* Discord signs every interaction request over `timestamp || rawBody` with the
|
|
9
|
+
* application's Ed25519 key; the route MUST verify against the EXACT raw bytes
|
|
10
|
+
* (no JSON re-stringify) using the case-insensitive `x-signature-ed25519` /
|
|
11
|
+
* `x-signature-timestamp` headers. We use `node:crypto`'s native Ed25519
|
|
12
|
+
* (`verify("ed25519", ...)`) wrapping the raw 32-byte public key in a SPKI DER
|
|
13
|
+
* prefix — NO `tweetnacl`. Verification FAILS CLOSED: a missing header, a
|
|
14
|
+
* malformed key, or a thrown verify all resolve to `false`.
|
|
15
|
+
*
|
|
16
|
+
* The identify UX is a private-MODAL loop (every step is ephemeral to the
|
|
17
|
+
* invoker; no secret ever rides in a `custom_id`, a rendered message, or a log):
|
|
18
|
+
* A `/link` APPLICATION_COMMAND → open the email modal (type 9).
|
|
19
|
+
* B email MODAL_SUBMIT → defer (type 5, flags 64), then out of
|
|
20
|
+
* band mint+send and PATCH @original with
|
|
21
|
+
* an "Enter code" button.
|
|
22
|
+
* C "Enter code" MESSAGE_COMPONENT → open the code modal (type 9). This button
|
|
23
|
+
* is the MANDATORY bridge: Discord forbids
|
|
24
|
+
* returning a modal from a MODAL_SUBMIT, so
|
|
25
|
+
* a component click sits between the two
|
|
26
|
+
* modals.
|
|
27
|
+
* D code MODAL_SUBMIT → defer (type 5, flags 64), then out of band
|
|
28
|
+
* redeem+resolve and PATCH @original with a
|
|
29
|
+
* Components-V2 success card (or plain text
|
|
30
|
+
* on failure).
|
|
31
|
+
* Fallback `/verify <code>` slash → INLINE ephemeral redeem (kept for clients
|
|
32
|
+
* that can't use the button/modal).
|
|
33
|
+
*
|
|
34
|
+
* Re B/D deferral: a modal-open is the INITIAL response and does zero work, so it
|
|
35
|
+
* is instant (within Discord's 3s window). The modal SUBMITS do slow work (a
|
|
36
|
+
* provider HTTP send; a DB transaction) that can exceed 3s under cold-start /
|
|
37
|
+
* cross-region latency, so they DEFER first and PATCH @original out of band (the
|
|
38
|
+
* interaction token is valid ~15 min).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// SPKI DER prefix for an Ed25519 public key (RFC 8410): the fixed 12-byte
|
|
42
|
+
// AlgorithmIdentifier + BIT STRING header preceding the raw 32-byte key.
|
|
43
|
+
const ED25519_SPKI_PREFIX = Buffer.from([
|
|
44
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Reject interaction requests whose `x-signature-timestamp` is more than this
|
|
48
|
+
// many seconds from now (in either direction) — a replay window. Discord signs
|
|
49
|
+
// `timestamp || rawBody`, so a captured-and-replayed request still verifies
|
|
50
|
+
// cryptographically; the timestamp bound caps how long such a capture stays
|
|
51
|
+
// usable.
|
|
52
|
+
const INTERACTION_REPLAY_WINDOW_SECONDS = 300;
|
|
53
|
+
|
|
54
|
+
function rawEd25519KeyToSpki(publicKeyHex: string) {
|
|
55
|
+
const raw = Buffer.from(publicKeyHex, "hex");
|
|
56
|
+
if (raw.length !== 32) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`invalid Discord public key: expected 32 bytes, got ${raw.length}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return createPublicKey({
|
|
62
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
63
|
+
format: "der",
|
|
64
|
+
type: "spki",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VerifyInteractionSignatureArgs {
|
|
69
|
+
/** The application's Ed25519 public key, hex (Developer Portal → General). */
|
|
70
|
+
publicKeyHex: string;
|
|
71
|
+
/** `x-signature-ed25519` header value (hex). */
|
|
72
|
+
signatureHex: string;
|
|
73
|
+
/** `x-signature-timestamp` header value. */
|
|
74
|
+
timestamp: string;
|
|
75
|
+
/** EXACT raw request body bytes (the route's `c.req.text()`). */
|
|
76
|
+
rawBody: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Verify a Discord interaction request. Returns `false` (fail closed) on ANY
|
|
81
|
+
* problem — absent signature/timestamp, bad key, or a verify throw. Discord
|
|
82
|
+
* signs `timestamp || rawBody`, so we concat in THAT order.
|
|
83
|
+
*/
|
|
84
|
+
export function verifyInteractionSignature(
|
|
85
|
+
args: VerifyInteractionSignatureArgs,
|
|
86
|
+
): boolean {
|
|
87
|
+
const { publicKeyHex, signatureHex, timestamp, rawBody } = args;
|
|
88
|
+
if (!publicKeyHex || !signatureHex || !timestamp) return false;
|
|
89
|
+
|
|
90
|
+
// Replay-window check BEFORE the (relatively expensive) ed25519 verify: a
|
|
91
|
+
// non-numeric or stale/future timestamp is rejected outright. The timestamp
|
|
92
|
+
// is covered by the signature, so an attacker cannot edit it post-capture
|
|
93
|
+
// without breaking the signature.
|
|
94
|
+
const ts = Number(timestamp);
|
|
95
|
+
if (
|
|
96
|
+
!Number.isFinite(ts) ||
|
|
97
|
+
Math.abs(Math.floor(Date.now() / 1000) - ts) >
|
|
98
|
+
INTERACTION_REPLAY_WINDOW_SECONDS
|
|
99
|
+
) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let signature: Buffer;
|
|
104
|
+
try {
|
|
105
|
+
signature = Buffer.from(signatureHex, "hex");
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (signature.length !== 64) return false;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const key = rawEd25519KeyToSpki(publicKeyHex);
|
|
113
|
+
const message = Buffer.concat([
|
|
114
|
+
Buffer.from(timestamp),
|
|
115
|
+
Buffer.from(rawBody),
|
|
116
|
+
]);
|
|
117
|
+
return edVerify(null, message, key, signature);
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Discord interaction type ids (the subset we branch on). */
|
|
124
|
+
export const InteractionType = {
|
|
125
|
+
PING: 1,
|
|
126
|
+
APPLICATION_COMMAND: 2,
|
|
127
|
+
MESSAGE_COMPONENT: 3,
|
|
128
|
+
APPLICATION_COMMAND_AUTOCOMPLETE: 4,
|
|
129
|
+
MODAL_SUBMIT: 5,
|
|
130
|
+
} as const;
|
|
131
|
+
|
|
132
|
+
/** Discord interaction-response type ids (the subset we emit). */
|
|
133
|
+
export const InteractionResponseType = {
|
|
134
|
+
PONG: 1,
|
|
135
|
+
/** Immediate visible reply (used with EPHEMERAL flags for the loop). */
|
|
136
|
+
CHANNEL_MESSAGE_WITH_SOURCE: 4,
|
|
137
|
+
/** "thinking…" ack — modal submits defer, then PATCH @original out of band. */
|
|
138
|
+
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5,
|
|
139
|
+
/** Open a modal — the INITIAL response to /link and the Enter-code button. */
|
|
140
|
+
MODAL: 9,
|
|
141
|
+
} as const;
|
|
142
|
+
|
|
143
|
+
/** Discord component type ids (the subset we emit). */
|
|
144
|
+
export const ComponentType = {
|
|
145
|
+
/** Legacy Action Row (wraps a Text Input / Button). */
|
|
146
|
+
ACTION_ROW: 1,
|
|
147
|
+
/** Button. */
|
|
148
|
+
BUTTON: 2,
|
|
149
|
+
/** Text Input (modal field). */
|
|
150
|
+
TEXT_INPUT: 4,
|
|
151
|
+
/** Text Display (Components V2 — required since V2 disables `content`). */
|
|
152
|
+
TEXT_DISPLAY: 10,
|
|
153
|
+
/** Container (Components V2 — the success-card wrapper). */
|
|
154
|
+
CONTAINER: 17,
|
|
155
|
+
/** Label (modern modal-field wrapper; we ALSO read it on inbound submits). */
|
|
156
|
+
LABEL: 18,
|
|
157
|
+
} as const;
|
|
158
|
+
|
|
159
|
+
/** Discord interaction-response / message flags (the subset we set). */
|
|
160
|
+
export const InteractionCallbackFlags = {
|
|
161
|
+
/** 64 — only the invoking user sees the reply. */
|
|
162
|
+
EPHEMERAL: 1 << 6,
|
|
163
|
+
/** 32768 — Components V2 (disables `content`/`embeds`). */
|
|
164
|
+
IS_COMPONENTS_V2: 1 << 15,
|
|
165
|
+
} as const;
|
|
166
|
+
|
|
167
|
+
/** The static `custom_id` step routers — NONE carries a secret/user data. */
|
|
168
|
+
export const CustomIds = {
|
|
169
|
+
EMAIL_MODAL: "discord_link_email_modal",
|
|
170
|
+
ENTER_CODE_BUTTON: "discord_link_enter_code",
|
|
171
|
+
CODE_MODAL: "discord_link_code_modal",
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
/** The Text Input `custom_id`s read out of the two modals' submits. */
|
|
175
|
+
const EMAIL_INPUT_ID = "email";
|
|
176
|
+
const CODE_INPUT_ID = "code";
|
|
177
|
+
|
|
178
|
+
/** Discord blurple (#5865F2) as a decimal accent_color for the V2 card. */
|
|
179
|
+
const BLURPLE_ACCENT = 0x5865f2;
|
|
180
|
+
|
|
181
|
+
/** A Discord interaction-response body (what the route 200s back). */
|
|
182
|
+
export interface InteractionResponse {
|
|
183
|
+
type: number;
|
|
184
|
+
data?: Record<string, unknown>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* How long a minted code is valid before redeem (15 minutes). Re-exported from
|
|
189
|
+
* the engine so the TTL has a SINGLE source — the copy string below derives its
|
|
190
|
+
* "N minutes" from this same value rather than hard-coding it twice.
|
|
191
|
+
*/
|
|
192
|
+
export { LINK_CODE_TTL_SECONDS };
|
|
193
|
+
|
|
194
|
+
/** The TTL rendered in user-facing copy, derived from the single source. */
|
|
195
|
+
const TTL_MINUTES = Math.round(LINK_CODE_TTL_SECONDS / 60);
|
|
196
|
+
|
|
197
|
+
/** Build an EPHEMERAL inline message response (type 4, flags 64). */
|
|
198
|
+
export function ephemeralReply(content: string): InteractionResponse {
|
|
199
|
+
return {
|
|
200
|
+
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
|
|
201
|
+
data: { content, flags: InteractionCallbackFlags.EPHEMERAL },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Build an EPHEMERAL deferred ack (type 5, flags 64) for a modal submit. */
|
|
206
|
+
function ephemeralDeferredAck(): InteractionResponse {
|
|
207
|
+
return {
|
|
208
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
209
|
+
data: { flags: InteractionCallbackFlags.EPHEMERAL },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The email-collection modal (the INITIAL response to `/link`). A modal is
|
|
215
|
+
* inherently private to the invoker, so it takes NO `flags`. The single Text
|
|
216
|
+
* Input's `custom_id` ("email") is read at submit; `max_length:254` mirrors the
|
|
217
|
+
* server-side RFC bound (but is client-only — the follow-up re-checks).
|
|
218
|
+
*/
|
|
219
|
+
function emailModalResponse(): InteractionResponse {
|
|
220
|
+
return {
|
|
221
|
+
type: InteractionResponseType.MODAL,
|
|
222
|
+
data: {
|
|
223
|
+
custom_id: CustomIds.EMAIL_MODAL,
|
|
224
|
+
title: "Link your email",
|
|
225
|
+
components: [
|
|
226
|
+
{
|
|
227
|
+
type: ComponentType.ACTION_ROW,
|
|
228
|
+
components: [
|
|
229
|
+
{
|
|
230
|
+
type: ComponentType.TEXT_INPUT,
|
|
231
|
+
custom_id: EMAIL_INPUT_ID,
|
|
232
|
+
style: 1,
|
|
233
|
+
label: "Email address",
|
|
234
|
+
placeholder: "you@example.com",
|
|
235
|
+
min_length: 3,
|
|
236
|
+
max_length: 254,
|
|
237
|
+
required: true,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The code-collection modal (the response to the Enter-code button click — the
|
|
248
|
+
* legal modal hop the modal→modal prohibition forces us through). `max_length`
|
|
249
|
+
* is generous; the engine redeem trims + bounds the code.
|
|
250
|
+
*/
|
|
251
|
+
function codeModalResponse(): InteractionResponse {
|
|
252
|
+
return {
|
|
253
|
+
type: InteractionResponseType.MODAL,
|
|
254
|
+
data: {
|
|
255
|
+
custom_id: CustomIds.CODE_MODAL,
|
|
256
|
+
title: "Enter your code",
|
|
257
|
+
components: [
|
|
258
|
+
{
|
|
259
|
+
type: ComponentType.ACTION_ROW,
|
|
260
|
+
components: [
|
|
261
|
+
{
|
|
262
|
+
type: ComponentType.TEXT_INPUT,
|
|
263
|
+
custom_id: CODE_INPUT_ID,
|
|
264
|
+
style: 1,
|
|
265
|
+
label: "6-digit code",
|
|
266
|
+
placeholder: "123456",
|
|
267
|
+
min_length: 1,
|
|
268
|
+
max_length: 12,
|
|
269
|
+
required: true,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The "check your inbox" PATCH body that replaces the deferred email-submit ack.
|
|
280
|
+
* It carries the Enter-code button (the bridge to the code modal) and NEVER
|
|
281
|
+
* echoes the email address — the user just typed it into the modal, and not
|
|
282
|
+
* re-stating it keeps the address out of the rendered message. The "N minutes"
|
|
283
|
+
* is derived from {@link LINK_CODE_TTL_SECONDS}.
|
|
284
|
+
*/
|
|
285
|
+
function checkInboxBody(): Record<string, unknown> {
|
|
286
|
+
return {
|
|
287
|
+
content:
|
|
288
|
+
"Check your inbox for a 6-digit code, then tap the button below to " +
|
|
289
|
+
`enter it. The code expires in ${TTL_MINUTES} minutes. Didn't get it? ` +
|
|
290
|
+
"Re-run /link and double-check the address.",
|
|
291
|
+
components: [
|
|
292
|
+
{
|
|
293
|
+
type: ComponentType.ACTION_ROW,
|
|
294
|
+
components: [
|
|
295
|
+
{
|
|
296
|
+
type: ComponentType.BUTTON,
|
|
297
|
+
style: 1,
|
|
298
|
+
label: "Enter code",
|
|
299
|
+
custom_id: CustomIds.ENTER_CODE_BUTTON,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The Components-V2 ephemeral success card PATCHed in after a successful redeem.
|
|
309
|
+
* V2 (flag 32768) DISABLES `content`/`embeds`, so the text is Text Display
|
|
310
|
+
* components inside a Container; the EPHEMERAL bit (64) PERSISTS — the body
|
|
311
|
+
* carries `flags: 32832` (64 | 32768), NEVER 32768 alone (which would drop
|
|
312
|
+
* ephemerality on the rendered card). The card does NOT print the email.
|
|
313
|
+
*/
|
|
314
|
+
function successCardBody(): Record<string, unknown> {
|
|
315
|
+
return {
|
|
316
|
+
flags:
|
|
317
|
+
InteractionCallbackFlags.EPHEMERAL |
|
|
318
|
+
InteractionCallbackFlags.IS_COMPONENTS_V2,
|
|
319
|
+
components: [
|
|
320
|
+
{
|
|
321
|
+
type: ComponentType.CONTAINER,
|
|
322
|
+
accent_color: BLURPLE_ACCENT,
|
|
323
|
+
components: [
|
|
324
|
+
{ type: ComponentType.TEXT_DISPLAY, content: "**You're all set**" },
|
|
325
|
+
{
|
|
326
|
+
type: ComponentType.TEXT_DISPLAY,
|
|
327
|
+
content: "Your email is now linked to your Discord account.",
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A plain ephemeral text edit (failure paths — a simple `content` PATCH). */
|
|
336
|
+
function plainTextBody(content: string): Record<string, unknown> {
|
|
337
|
+
return { content };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** RFC5322-lite email shape check — a cheap guard before any mint/send. */
|
|
341
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
342
|
+
export function isLikelyEmail(value: string): boolean {
|
|
343
|
+
return EMAIL_RE.test(value);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The RFC 5321 practical max length for an addr-spec — the authoritative cap. */
|
|
347
|
+
const EMAIL_MAX_LENGTH = 254;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A minimal shape for any inbound interaction payload. Every incoming type
|
|
351
|
+
* (PING/command/component/modal-submit) carries the invoking user (guild →
|
|
352
|
+
* `member.user.id`, DM → `user.id`) and the per-interaction `token`.
|
|
353
|
+
*/
|
|
354
|
+
interface InteractionPayload {
|
|
355
|
+
type?: number;
|
|
356
|
+
token?: string;
|
|
357
|
+
data?: {
|
|
358
|
+
name?: string;
|
|
359
|
+
custom_id?: string;
|
|
360
|
+
options?: Array<{ name?: string; value?: unknown }>;
|
|
361
|
+
components?: unknown;
|
|
362
|
+
};
|
|
363
|
+
member?: { user?: { id?: string } };
|
|
364
|
+
user?: { id?: string };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The invoking Discord user snowflake (guild → member.user, DM → user). */
|
|
368
|
+
function readUserId(payload: InteractionPayload): string | undefined {
|
|
369
|
+
return payload.member?.user?.id ?? payload.user?.id;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The flat, parsed shape of a verified APPLICATION_COMMAND interaction — the
|
|
374
|
+
* invoking user (the identity the code is BOUND to), the interaction token, the
|
|
375
|
+
* lowercased command name, and the string options.
|
|
376
|
+
*/
|
|
377
|
+
export interface ParsedCommand {
|
|
378
|
+
/** The invoking Discord user snowflake — the identity the code is bound to. */
|
|
379
|
+
discordUserId: string;
|
|
380
|
+
/** The interaction token — authenticates the deferred follow-up. */
|
|
381
|
+
token: string;
|
|
382
|
+
/** Lowercased command name. */
|
|
383
|
+
name: string;
|
|
384
|
+
/** Flat string options keyed by option name. */
|
|
385
|
+
options: Record<string, string>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pull the invoking user + token + command name + string options from a verified
|
|
390
|
+
* type-2 payload. Guild commands carry the user under `member.user`; DM commands
|
|
391
|
+
* under `user`. Returns null for any non-command / malformed payload (PING, a
|
|
392
|
+
* payload missing `data.name`, etc.) so callers fall through to the deferred ack.
|
|
393
|
+
*/
|
|
394
|
+
export function parseCommand(
|
|
395
|
+
payload: InteractionPayload,
|
|
396
|
+
): ParsedCommand | null {
|
|
397
|
+
if (payload.type !== InteractionType.APPLICATION_COMMAND) return null;
|
|
398
|
+
const discordUserId = readUserId(payload);
|
|
399
|
+
const name = payload.data?.name;
|
|
400
|
+
const token = payload.token;
|
|
401
|
+
if (!discordUserId || !name || !token) return null;
|
|
402
|
+
const options: Record<string, string> = {};
|
|
403
|
+
for (const opt of payload.data?.options ?? []) {
|
|
404
|
+
if (opt.name && typeof opt.value === "string") {
|
|
405
|
+
options[opt.name] = opt.value;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { discordUserId, token, name: name.toLowerCase(), options };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The parsed shape of a verified MESSAGE_COMPONENT (button click). */
|
|
412
|
+
export interface ParsedComponent {
|
|
413
|
+
discordUserId: string;
|
|
414
|
+
/** The interaction token — REQUIRED (authenticates any follow-up). */
|
|
415
|
+
token: string;
|
|
416
|
+
/** The clicked component's static `custom_id`. */
|
|
417
|
+
customId: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Pull the invoking user + token + `custom_id` from a verified type-3 payload.
|
|
422
|
+
* Returns null for any non-component / malformed payload (mirrors parseCommand:
|
|
423
|
+
* a missing user/token/custom_id falls through to the deferred ack).
|
|
424
|
+
*/
|
|
425
|
+
export function parseComponent(
|
|
426
|
+
payload: InteractionPayload,
|
|
427
|
+
): ParsedComponent | null {
|
|
428
|
+
if (payload.type !== InteractionType.MESSAGE_COMPONENT) return null;
|
|
429
|
+
const discordUserId = readUserId(payload);
|
|
430
|
+
const token = payload.token;
|
|
431
|
+
const customId = payload.data?.custom_id;
|
|
432
|
+
if (!discordUserId || !token || !customId) return null;
|
|
433
|
+
return { discordUserId, token, customId };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The parsed shape of a verified MODAL_SUBMIT. */
|
|
437
|
+
export interface ParsedModalSubmit {
|
|
438
|
+
discordUserId: string;
|
|
439
|
+
/** The interaction token — REQUIRED (authenticates the deferred PATCH). */
|
|
440
|
+
token: string;
|
|
441
|
+
/** The submitting modal's static `custom_id` step router. */
|
|
442
|
+
modalId: string;
|
|
443
|
+
/** The submitted input values, keyed by each input's inner `custom_id`. */
|
|
444
|
+
values: Record<string, string>;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Read a modal input's value by its inner `custom_id`, POSITION-INDEPENDENT and
|
|
449
|
+
* robust to BOTH modal shapes: the legacy Action Row (type 1) wrapping a Text
|
|
450
|
+
* Input (type 4), AND the modern Label (type 18) wrapping a single nested
|
|
451
|
+
* `component`/`components`. Walks the tree, returns the first matching Text
|
|
452
|
+
* Input's string `value`, and NEVER throws on a malformed shape (returns
|
|
453
|
+
* undefined → the handler replies the validation message).
|
|
454
|
+
*/
|
|
455
|
+
export function readModalValue(
|
|
456
|
+
payload: InteractionPayload,
|
|
457
|
+
inputCustomId: string,
|
|
458
|
+
): string | undefined {
|
|
459
|
+
const roots = payload.data?.components;
|
|
460
|
+
if (!Array.isArray(roots)) return undefined;
|
|
461
|
+
// Iterative DFS over the (shallow) component tree; tolerant of either shape.
|
|
462
|
+
const stack: unknown[] = [...roots];
|
|
463
|
+
while (stack.length > 0) {
|
|
464
|
+
const node = stack.pop();
|
|
465
|
+
if (!node || typeof node !== "object") continue;
|
|
466
|
+
const n = node as {
|
|
467
|
+
type?: number;
|
|
468
|
+
custom_id?: string;
|
|
469
|
+
value?: unknown;
|
|
470
|
+
component?: unknown;
|
|
471
|
+
components?: unknown;
|
|
472
|
+
};
|
|
473
|
+
if (
|
|
474
|
+
n.type === ComponentType.TEXT_INPUT &&
|
|
475
|
+
n.custom_id === inputCustomId &&
|
|
476
|
+
typeof n.value === "string"
|
|
477
|
+
) {
|
|
478
|
+
return n.value;
|
|
479
|
+
}
|
|
480
|
+
if (Array.isArray(n.components)) stack.push(...n.components);
|
|
481
|
+
if (n.component) stack.push(n.component);
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Parse a verified type-5 payload into the invoking user + token + modal id +
|
|
488
|
+
* input values. Returns null when the user, token, or modal `custom_id` is
|
|
489
|
+
* missing (mirrors parseCommand) so callers fall through to the deferred ack.
|
|
490
|
+
*/
|
|
491
|
+
export function parseModalSubmit(
|
|
492
|
+
payload: InteractionPayload,
|
|
493
|
+
): ParsedModalSubmit | null {
|
|
494
|
+
if (payload.type !== InteractionType.MODAL_SUBMIT) return null;
|
|
495
|
+
const discordUserId = readUserId(payload);
|
|
496
|
+
const token = payload.token;
|
|
497
|
+
const modalId = payload.data?.custom_id;
|
|
498
|
+
if (!discordUserId || !token || !modalId) return null;
|
|
499
|
+
const values: Record<string, string> = {};
|
|
500
|
+
for (const id of [EMAIL_INPUT_ID, CODE_INPUT_ID]) {
|
|
501
|
+
const v = readModalValue(payload, id);
|
|
502
|
+
if (typeof v === "string") values[id] = v;
|
|
503
|
+
}
|
|
504
|
+
return { discordUserId, token, modalId, values };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Result of minting a code (delegates to the engine throttle+store). */
|
|
508
|
+
export type LinkMintResult =
|
|
509
|
+
| { ok: true; code: string }
|
|
510
|
+
| { ok: false; reason: "throttled" };
|
|
511
|
+
|
|
512
|
+
/** Result of redeeming a code (delegates to the engine store). */
|
|
513
|
+
export type LinkRedeemResult =
|
|
514
|
+
| { ok: true; email: string }
|
|
515
|
+
| { ok: false; reason: "invalid" | "expired" | "used" | "wrong_user" };
|
|
516
|
+
|
|
517
|
+
/** Result of recording a verify attempt (anti-guessing throttle). */
|
|
518
|
+
export type VerifyAttemptResult = { throttled: boolean };
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Dependencies the link → verify loop runs against. The connector wires these
|
|
522
|
+
* from its config closure so the plugin holds NO engine internals (no db, no
|
|
523
|
+
* Redis, no `process.env`) — exactly how `resolveContact`/`saveDerived` are
|
|
524
|
+
* injected. `mintCode`/`redeemCode` delegate to the engine's table-backed
|
|
525
|
+
* `createLinkCode`/`redeemLinkCode` (throttle + single-use + identity-binding +
|
|
526
|
+
* TTL live there); `sendLinkCode` emails the minted code via the consumer's
|
|
527
|
+
* transactional mailer.
|
|
528
|
+
*/
|
|
529
|
+
export interface InteractionDeps {
|
|
530
|
+
/** Discord application id — the deferred follow-up PATCH target. */
|
|
531
|
+
applicationId: string;
|
|
532
|
+
/**
|
|
533
|
+
* Mint a single-use code for `(discordUserId, email)`. The engine enforces the
|
|
534
|
+
* anti-email-bomb throttle BEFORE minting and returns `{ ok:false }` when over
|
|
535
|
+
* cap (no code minted, nothing to send). A thrown error (e.g. DB down) MUST
|
|
536
|
+
* propagate so the caller fails CLOSED — never fall through to an unthrottled
|
|
537
|
+
* send.
|
|
538
|
+
*/
|
|
539
|
+
mintCode: (args: {
|
|
540
|
+
discordUserId: string;
|
|
541
|
+
email: string;
|
|
542
|
+
}) => Promise<LinkMintResult>;
|
|
543
|
+
/** Email the minted code to the target address (transactional, bypasses prefs). */
|
|
544
|
+
sendLinkCode: (args: { email: string; code: string }) => Promise<void>;
|
|
545
|
+
/**
|
|
546
|
+
* Redeem a typed code for the bound email — single-use, TTL-enforced, and
|
|
547
|
+
* identity-bound to `discordUserId` (the engine re-checks the binding).
|
|
548
|
+
*/
|
|
549
|
+
redeemCode: (args: {
|
|
550
|
+
discordUserId: string;
|
|
551
|
+
code: string;
|
|
552
|
+
}) => Promise<LinkRedeemResult>;
|
|
553
|
+
/** Attach the verified email to the Discord identity (resolveOrCreateContact). */
|
|
554
|
+
resolveContact: (args: { discordId: string; email: string }) => Promise<void>;
|
|
555
|
+
/**
|
|
556
|
+
* OPTIONAL anti-guessing throttle, checked BEFORE redeem. Caps how many codes
|
|
557
|
+
* one Discord user may try per window so brute-force traffic can't grind the
|
|
558
|
+
* store. BEST-EFFORT, fail-OPEN: a throttle-store outage MUST NOT block a
|
|
559
|
+
* legitimate redeem (the per-mint caps + the redeem identity-binding are the
|
|
560
|
+
* real backstops — see the `/verify`/code-modal throttle catch). When omitted,
|
|
561
|
+
* no per-attempt cap is applied (each redeem is still identity-bound +
|
|
562
|
+
* single-use, so guessing another account's code is impossible).
|
|
563
|
+
*/
|
|
564
|
+
recordVerifyAttempt?: (args: {
|
|
565
|
+
discordUserId: string;
|
|
566
|
+
}) => Promise<VerifyAttemptResult>;
|
|
567
|
+
/** The deferred-follow-up editor (PATCH @original); overridable in tests. */
|
|
568
|
+
editResponse?: typeof editInteractionResponse;
|
|
569
|
+
logger: { error: (msg: string, meta?: unknown) => void };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Normalize + bound-check a typed email; null when it isn't a usable address. */
|
|
573
|
+
function normalizeEmail(raw: string): string | null {
|
|
574
|
+
const email = raw.trim().toLowerCase();
|
|
575
|
+
// EMAIL_MAX_LENGTH (254, RFC 5321) is the authoritative cap — the modal's
|
|
576
|
+
// client-side `max_length:254` can be bypassed by a crafted signed payload.
|
|
577
|
+
if (!isLikelyEmail(email) || email.length > EMAIL_MAX_LENGTH) return null;
|
|
578
|
+
return email;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The async work behind the email modal submit (step B): validate → throttle +
|
|
583
|
+
* mint → email → PATCH the deferred ack into the "check your inbox" message with
|
|
584
|
+
* the Enter-code button. Fire-and-forget from `handleInteraction` (the type-5
|
|
585
|
+
* ack already went back inside the 3s window). NEVER throws to the caller —
|
|
586
|
+
* every failure path edits an apologetic message and logs ONLY a short reason
|
|
587
|
+
* (never the code or the email). No PATCH body echoes the email address.
|
|
588
|
+
*/
|
|
589
|
+
async function runEmailFollowUp(
|
|
590
|
+
submit: ParsedModalSubmit,
|
|
591
|
+
rawEmail: string,
|
|
592
|
+
deps: InteractionDeps,
|
|
593
|
+
): Promise<void> {
|
|
594
|
+
const edit = deps.editResponse ?? editInteractionResponse;
|
|
595
|
+
let body: Record<string, unknown>;
|
|
596
|
+
try {
|
|
597
|
+
const email = normalizeEmail(rawEmail);
|
|
598
|
+
if (!email) {
|
|
599
|
+
body = plainTextBody(
|
|
600
|
+
"That doesn't look like an email address. Run /link to try again.",
|
|
601
|
+
);
|
|
602
|
+
} else {
|
|
603
|
+
// mintCode runs the throttle FIRST and only mints when under cap. A thrown
|
|
604
|
+
// error here (Redis/DB down) is caught below → apologetic reply, NO send —
|
|
605
|
+
// an unthrottled send would defeat the email-bomb control.
|
|
606
|
+
const minted = await deps.mintCode({
|
|
607
|
+
discordUserId: submit.discordUserId,
|
|
608
|
+
email,
|
|
609
|
+
});
|
|
610
|
+
if (!minted.ok) {
|
|
611
|
+
body = plainTextBody(
|
|
612
|
+
"You've requested too many codes recently. Please try again later.",
|
|
613
|
+
);
|
|
614
|
+
} else {
|
|
615
|
+
await deps.sendLinkCode({ email, code: minted.code });
|
|
616
|
+
// Success → the "check your inbox" message + Enter-code button. The
|
|
617
|
+
// email is NOT echoed (it lived in the modal the user just typed).
|
|
618
|
+
body = checkInboxBody();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
// Never echo the error (it may carry email/provider detail) — log a short
|
|
623
|
+
// reason only, and NEVER the code or the email.
|
|
624
|
+
deps.logger.error("discord link email follow-up failed", {
|
|
625
|
+
error: err instanceof Error ? err.message : String(err),
|
|
626
|
+
});
|
|
627
|
+
body = plainTextBody(
|
|
628
|
+
"Something went wrong sending your code. Please try /link again.",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
await edit({
|
|
633
|
+
applicationId: deps.applicationId,
|
|
634
|
+
token: submit.token,
|
|
635
|
+
body,
|
|
636
|
+
});
|
|
637
|
+
} catch (err) {
|
|
638
|
+
deps.logger.error("discord link email follow-up edit failed", {
|
|
639
|
+
error: err instanceof Error ? err.message : String(err),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The async work behind the code modal submit (step D): optional throttle
|
|
646
|
+
* (BEST-EFFORT, fail-OPEN) → redeem (single-use + TTL + identity-bound) →
|
|
647
|
+
* resolve → PATCH the deferred ack into the Components-V2 success card (or a
|
|
648
|
+
* plain text edit on failure). Fire-and-forget; NEVER throws; logs ONLY a short
|
|
649
|
+
* reason. No PATCH body echoes the email.
|
|
650
|
+
*/
|
|
651
|
+
async function runCodeFollowUp(
|
|
652
|
+
submit: ParsedModalSubmit,
|
|
653
|
+
rawCode: string,
|
|
654
|
+
deps: InteractionDeps,
|
|
655
|
+
): Promise<void> {
|
|
656
|
+
const edit = deps.editResponse ?? editInteractionResponse;
|
|
657
|
+
const body = await resolveCodeBody(submit, rawCode, deps);
|
|
658
|
+
try {
|
|
659
|
+
await edit({
|
|
660
|
+
applicationId: deps.applicationId,
|
|
661
|
+
token: submit.token,
|
|
662
|
+
body,
|
|
663
|
+
});
|
|
664
|
+
} catch (err) {
|
|
665
|
+
deps.logger.error("discord link code follow-up edit failed", {
|
|
666
|
+
error: err instanceof Error ? err.message : String(err),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Resolve the code modal submit to a PATCH body (success card or plain text),
|
|
673
|
+
* sharing the redeem + resolve flow with the `/verify` slash fallback's reply.
|
|
674
|
+
*/
|
|
675
|
+
async function resolveCodeBody(
|
|
676
|
+
submit: ParsedModalSubmit,
|
|
677
|
+
rawCode: string,
|
|
678
|
+
deps: InteractionDeps,
|
|
679
|
+
): Promise<Record<string, unknown>> {
|
|
680
|
+
const code = rawCode.trim();
|
|
681
|
+
if (!code) {
|
|
682
|
+
return plainTextBody(
|
|
683
|
+
"That code is invalid, expired, or already used. Run /link for a new one.",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
// Anti-guessing throttle BEFORE redeem (blunts brute-force traffic). BEST-
|
|
687
|
+
// EFFORT, fail-OPEN: a throttle-store outage LOGS and CONTINUES — the per-mint
|
|
688
|
+
// caps + the redeem identity-binding are the real backstops, so a missed
|
|
689
|
+
// throttle never enables cross-account guessing.
|
|
690
|
+
if (deps.recordVerifyAttempt) {
|
|
691
|
+
try {
|
|
692
|
+
const attempt = await deps.recordVerifyAttempt({
|
|
693
|
+
discordUserId: submit.discordUserId,
|
|
694
|
+
});
|
|
695
|
+
if (attempt.throttled) {
|
|
696
|
+
return plainTextBody(
|
|
697
|
+
"Too many verification attempts. Please wait a bit and try again.",
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
} catch (err) {
|
|
701
|
+
deps.logger.error("discord link verify throttle failed", {
|
|
702
|
+
error: err instanceof Error ? err.message : String(err),
|
|
703
|
+
});
|
|
704
|
+
// fall through to redeem (fail-open).
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
let result: LinkRedeemResult;
|
|
708
|
+
try {
|
|
709
|
+
result = await deps.redeemCode({
|
|
710
|
+
discordUserId: submit.discordUserId,
|
|
711
|
+
code,
|
|
712
|
+
});
|
|
713
|
+
} catch (err) {
|
|
714
|
+
deps.logger.error("discord link verify redeem failed", {
|
|
715
|
+
error: err instanceof Error ? err.message : String(err),
|
|
716
|
+
});
|
|
717
|
+
return plainTextBody(
|
|
718
|
+
"Something went wrong verifying your code. Please try again.",
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (!result.ok) {
|
|
722
|
+
// Collapse invalid/expired/used/wrong_user into one non-leaking message:
|
|
723
|
+
// never confirm a code exists for another account.
|
|
724
|
+
return plainTextBody(
|
|
725
|
+
"That code is invalid, expired, or already used. Run /link for a new one.",
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
await deps.resolveContact({
|
|
730
|
+
discordId: submit.discordUserId,
|
|
731
|
+
email: result.email,
|
|
732
|
+
});
|
|
733
|
+
} catch (err) {
|
|
734
|
+
deps.logger.error("discord link verify resolveContact failed", {
|
|
735
|
+
error: err instanceof Error ? err.message : String(err),
|
|
736
|
+
});
|
|
737
|
+
return plainTextBody(
|
|
738
|
+
"We verified your code but couldn't finish linking. Please try again.",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
// Success → the Components-V2 card. The email is NOT printed (it is the user's
|
|
742
|
+
// own address, typed two steps earlier — keep it out of the rendered card).
|
|
743
|
+
return successCardBody();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Answer an already-signature-verified interaction (the connector runs the
|
|
748
|
+
* ed25519 verify FIRST and only calls this on success). Routes on `payload.type`
|
|
749
|
+
* first, then the command name (type 2) or the static `custom_id` (types 3/5):
|
|
750
|
+
*
|
|
751
|
+
* - PING(1) → PONG(1).
|
|
752
|
+
* - APPLICATION_COMMAND /link → open the email modal (type 9; zero work,
|
|
753
|
+
* so instant within 3s).
|
|
754
|
+
* - APPLICATION_COMMAND /verify <code> → INLINE ephemeral redeem (the slash
|
|
755
|
+
* fallback; no deferral — DB-bounded work).
|
|
756
|
+
* - MESSAGE_COMPONENT Enter-code → open the code modal (type 9; the legal
|
|
757
|
+
* hop the modal→modal ban forces).
|
|
758
|
+
* - MODAL_SUBMIT email → DEFER (type-5 ephemeral ack) + out-of-band
|
|
759
|
+
* mint+send+PATCH (the send is a provider
|
|
760
|
+
* HTTP call that can exceed 3s).
|
|
761
|
+
* - MODAL_SUBMIT code → DEFER + out-of-band redeem+resolve+PATCH.
|
|
762
|
+
*
|
|
763
|
+
* Without `deps` (PING/PONG-only callers) OR for a payload we don't serve,
|
|
764
|
+
* returns the historical deferred ack so Discord doesn't error.
|
|
765
|
+
*/
|
|
766
|
+
export async function handleInteraction(
|
|
767
|
+
payload: InteractionPayload,
|
|
768
|
+
deps?: InteractionDeps,
|
|
769
|
+
): Promise<InteractionResponse> {
|
|
770
|
+
if (payload.type === InteractionType.PING) {
|
|
771
|
+
return { type: InteractionResponseType.PONG };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// No deps → historical deferred ack (preserves PING/PONG-only callers).
|
|
775
|
+
if (!deps) {
|
|
776
|
+
return {
|
|
777
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// --- type 2: APPLICATION_COMMAND -----------------------------------------
|
|
782
|
+
const command = parseCommand(payload);
|
|
783
|
+
if (command) {
|
|
784
|
+
if (command.name === "link") {
|
|
785
|
+
// Step A — open the email modal. A modal IS the initial response and does
|
|
786
|
+
// zero work, so it is instant (no deferral needed).
|
|
787
|
+
return emailModalResponse();
|
|
788
|
+
}
|
|
789
|
+
if (command.name === "verify") {
|
|
790
|
+
// Fallback slash path — INLINE ephemeral redeem (kept for clients that
|
|
791
|
+
// can't use the button/modal). DB-bounded, so no deferral.
|
|
792
|
+
const body = await resolveCodeBody(
|
|
793
|
+
{
|
|
794
|
+
discordUserId: command.discordUserId,
|
|
795
|
+
token: command.token,
|
|
796
|
+
modalId: "verify",
|
|
797
|
+
values: {},
|
|
798
|
+
},
|
|
799
|
+
command.options.code ?? "",
|
|
800
|
+
deps,
|
|
801
|
+
);
|
|
802
|
+
// resolveCodeBody returns either a plain `{ content }` (failure) or the
|
|
803
|
+
// V2 success card. For the inline /verify reply, render the success as a
|
|
804
|
+
// simple ephemeral text (the slash path predates V2 cards).
|
|
805
|
+
if (typeof body.content === "string") {
|
|
806
|
+
return ephemeralReply(body.content);
|
|
807
|
+
}
|
|
808
|
+
return ephemeralReply(
|
|
809
|
+
"Linked your email to your Discord account. You're all set.",
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
// A command we don't serve — deferred ack so Discord doesn't error.
|
|
813
|
+
return {
|
|
814
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// --- type 3: MESSAGE_COMPONENT (the Enter-code bridge button) -------------
|
|
819
|
+
const component = parseComponent(payload);
|
|
820
|
+
if (component) {
|
|
821
|
+
if (component.customId === CustomIds.ENTER_CODE_BUTTON) {
|
|
822
|
+
// Step C — open the code modal (legal from a component; the modal→modal
|
|
823
|
+
// ban is why a button sits between the two modals).
|
|
824
|
+
return codeModalResponse();
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// --- type 5: MODAL_SUBMIT -------------------------------------------------
|
|
832
|
+
const submit = parseModalSubmit(payload);
|
|
833
|
+
if (submit) {
|
|
834
|
+
if (submit.modalId === CustomIds.EMAIL_MODAL) {
|
|
835
|
+
// Validate SYNCHRONOUSLY so a bad address gets an INSTANT inline ephemeral
|
|
836
|
+
// error (type 4 — legal for a modal submit; only type-9 modals aren't).
|
|
837
|
+
// This avoids deferring (and thus the racy follow-up edit) for the most
|
|
838
|
+
// common mistake — a typo'd / non-email value. Only a valid address defers
|
|
839
|
+
// into the out-of-band mint+send+PATCH (step B).
|
|
840
|
+
const email = normalizeEmail(submit.values[EMAIL_INPUT_ID] ?? "");
|
|
841
|
+
if (!email) {
|
|
842
|
+
return ephemeralReply(
|
|
843
|
+
"That doesn't look like a valid email address — check it and run " +
|
|
844
|
+
"/link to try again.",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
void runEmailFollowUp(submit, email, deps);
|
|
848
|
+
return ephemeralDeferredAck();
|
|
849
|
+
}
|
|
850
|
+
if (submit.modalId === CustomIds.CODE_MODAL) {
|
|
851
|
+
// Step D — DEFER first, then redeem+resolve+PATCH out of band.
|
|
852
|
+
void runCodeFollowUp(submit, submit.values[CODE_INPUT_ID] ?? "", deps);
|
|
853
|
+
return ephemeralDeferredAck();
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Anything else (autocomplete, an unparseable payload) — deferred ack.
|
|
861
|
+
return {
|
|
862
|
+
type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
863
|
+
};
|
|
864
|
+
}
|