@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.
@@ -0,0 +1,541 @@
1
+ import {
2
+ type ConnectorCtx,
3
+ type ConnectorHandlers,
4
+ type DefinedConnector,
5
+ defineConnector,
6
+ type IngestEvent,
7
+ } from "@hogsend/engine";
8
+ import {
9
+ handleInteraction,
10
+ type LinkMintResult,
11
+ type LinkRedeemResult,
12
+ type VerifyAttemptResult,
13
+ verifyInteractionSignature,
14
+ } from "./connect/interactions.js";
15
+ import { memberLinkToContactPatch } from "./connect/member-link.js";
16
+ import {
17
+ type DiscordCurrentUser,
18
+ exchangeDiscordCode,
19
+ getCurrentUser,
20
+ } from "./connect/oauth.js";
21
+ import { DISCORD_EPOCH, DISCORD_PROVIDER_ID } from "./constants.js";
22
+ import { DiscordEvents } from "./events.js";
23
+ import type {
24
+ DiscordGuildMemberAdd,
25
+ DiscordMessageCreate,
26
+ DiscordPresenceUpdate,
27
+ DiscordReactionAdd,
28
+ } from "./types.js";
29
+
30
+ /**
31
+ * An {@link IngestEvent} carrying the Discord-identity key. The engine's
32
+ * `IngestEvent` gains an optional `discordId` in the schema+identity pass
33
+ * (spec §3.4); until that lands this intersection keeps the transform
34
+ * type-checking AND is forward-compatible (the intersection collapses to the
35
+ * widened `IngestEvent` once `discordId` is native).
36
+ */
37
+ type DiscordIngestEvent = IngestEvent & { discordId?: string };
38
+
39
+ /**
40
+ * Discord identity → Hogsend contact key, namespaced so snowflakes never
41
+ * collide with another platform's numeric ids.
42
+ */
43
+ function discordUserKey(discordUserId: string): string {
44
+ return `discord:${discordUserId}`;
45
+ }
46
+
47
+ /** Discord snowflake → Date (ms timestamp = bits 22+ over the Discord epoch). */
48
+ function snowflakeToDate(id: string): Date {
49
+ return new Date(Number((BigInt(id) >> 22n) + DISCORD_EPOCH));
50
+ }
51
+
52
+ /**
53
+ * The NON-KEY Discord metadata object merged under `contacts.properties.discord`
54
+ * (the engine deep-merges this single sub-object — see `DEEP_MERGE_KEYS` in
55
+ * `lib/contacts.ts` — so each event need only carry the fields IT knows; absent
56
+ * fields are preserved from prior events). `discord_id` stays the sole identity
57
+ * key (the `discordId` on the IngestEvent); this object is decorative only.
58
+ *
59
+ * `last_seen` is DERIVED Hogsend-side (stamped from `occurredAt`, NEVER read
60
+ * from Discord), so it is always present; everything else is omitted when the
61
+ * source dispatch doesn't carry it. `null` is never emitted (it would CLEAR the
62
+ * sub-key under the engine's null-strip), so a `global_name`/`avatar` Discord
63
+ * reports as `null` is simply left off.
64
+ */
65
+ function discordMetadata(opts: {
66
+ id: string;
67
+ lastSeen: Date;
68
+ username?: string | null;
69
+ globalName?: string | null;
70
+ avatar?: string | null;
71
+ joinedAt?: string | null;
72
+ roles?: string[];
73
+ }): Record<string, unknown> {
74
+ const meta: Record<string, unknown> = {
75
+ id: opts.id,
76
+ last_seen: opts.lastSeen.toISOString(),
77
+ };
78
+ if (typeof opts.username === "string") meta.username = opts.username;
79
+ if (typeof opts.globalName === "string") meta.global_name = opts.globalName;
80
+ if (typeof opts.avatar === "string") meta.avatar = opts.avatar;
81
+ if (typeof opts.joinedAt === "string") meta.joined_at = opts.joinedAt;
82
+ if (opts.roles && opts.roles.length > 0) meta.roles = opts.roles;
83
+ return meta;
84
+ }
85
+
86
+ /**
87
+ * The Discord INBOUND connector (gateway transport). Its transform is the
88
+ * transport-invariant heart: a raw Gateway dispatch (wrapped `{ __t, d }` by
89
+ * the gateway worker's ingress client) → an {@link IngestEvent} | null.
90
+ *
91
+ * Design notes (binding §2.4):
92
+ * - MESSAGE_CONTENT gating is surfaced as `hasContent` (the bot needs the
93
+ * privileged intent to read text; we never assume it).
94
+ * - `lastSeenDiscordAt` is DERIVED Hogsend-side (stamped here, NEVER read from
95
+ * Discord) on every inbound message/reaction/presence.
96
+ * - presence "active" is DERIVED by collapsing every non-offline status.
97
+ * - bot/system/webhook noise is dropped (returns null).
98
+ * - each event carries a deterministic `idempotencyKey` so the at-least-once
99
+ * Gateway (RESUME replays) dedups in `user_events`.
100
+ */
101
+ export const discordConnector: DefinedConnector = defineConnector({
102
+ meta: {
103
+ id: DISCORD_PROVIDER_ID,
104
+ name: "Discord",
105
+ transport: "gateway",
106
+ description:
107
+ "Inbound Discord activity (messages, reactions, joins, presence) → " +
108
+ "IngestEvent, via a long-lived Gateway worker.",
109
+ },
110
+ // Gateway connectors hold NO inboundVerify. The outbound bot token is pulled
111
+ // from the derived credential at worker boot (StoredCredentialRef).
112
+ credential: { providerId: DISCORD_PROVIDER_ID, kind: "derived" },
113
+
114
+ // raw = { __t: <dispatch type>, d: <Discord `d` payload> } wrapped by the
115
+ // gateway worker's ingress client. ctx.transport === "gateway".
116
+ async transform(
117
+ raw: unknown,
118
+ ctx: ConnectorCtx,
119
+ ): Promise<IngestEvent | null> {
120
+ const envelope = raw as { __t: keyof typeof DiscordEvents; d: unknown };
121
+ switch (envelope.__t) {
122
+ case "MESSAGE_CREATE": {
123
+ const d = envelope.d as DiscordMessageCreate;
124
+ if (d.author?.bot || d.webhook_id) return null;
125
+ const occurredAt = snowflakeToDate(d.id);
126
+ const event: DiscordIngestEvent = {
127
+ event: DiscordEvents.MESSAGE_CREATE,
128
+ userId: discordUserKey(d.author.id),
129
+ discordId: d.author.id,
130
+ eventProperties: {
131
+ source: "discord",
132
+ channelId: d.channel_id,
133
+ guildId: d.guild_id ?? null,
134
+ messageId: d.id,
135
+ hasContent: typeof d.content === "string" && d.content.length > 0,
136
+ },
137
+ contactProperties: {
138
+ discord: discordMetadata({
139
+ id: d.author.id,
140
+ username: d.author.username,
141
+ globalName: d.author.global_name,
142
+ avatar: d.author.avatar,
143
+ lastSeen: occurredAt,
144
+ }),
145
+ },
146
+ occurredAt,
147
+ idempotencyKey: `discord:msg:${d.id}`,
148
+ };
149
+ return event;
150
+ }
151
+ case "MESSAGE_REACTION_ADD": {
152
+ const d = envelope.d as DiscordReactionAdd;
153
+ const occurredAt = new Date();
154
+ const event: DiscordIngestEvent = {
155
+ event: DiscordEvents.MESSAGE_REACTION_ADD,
156
+ userId: discordUserKey(d.user_id),
157
+ discordId: d.user_id,
158
+ eventProperties: {
159
+ source: "discord",
160
+ channelId: d.channel_id,
161
+ guildId: d.guild_id ?? null,
162
+ messageId: d.message_id,
163
+ emoji: d.emoji?.name ?? null,
164
+ },
165
+ contactProperties: {
166
+ discord: discordMetadata({ id: d.user_id, lastSeen: occurredAt }),
167
+ },
168
+ occurredAt,
169
+ idempotencyKey:
170
+ `discord:react:${d.message_id}:${d.user_id}:` +
171
+ `${d.emoji?.name ?? ""}`,
172
+ };
173
+ return event;
174
+ }
175
+ case "GUILD_MEMBER_ADD": {
176
+ const d = envelope.d as DiscordGuildMemberAdd;
177
+ if (!d.user || d.user.bot) return null;
178
+ const occurredAt = new Date();
179
+ const event: DiscordIngestEvent = {
180
+ event: DiscordEvents.GUILD_MEMBER_ADD,
181
+ userId: discordUserKey(d.user.id),
182
+ discordId: d.user.id,
183
+ eventProperties: {
184
+ source: "discord",
185
+ guildId: d.guild_id,
186
+ joinedAt: d.joined_at ?? occurredAt.toISOString(),
187
+ },
188
+ contactProperties: {
189
+ discord: discordMetadata({
190
+ id: d.user.id,
191
+ username: d.user.username,
192
+ globalName: d.user.global_name,
193
+ avatar: d.user.avatar,
194
+ joinedAt: d.joined_at ?? occurredAt.toISOString(),
195
+ roles: d.roles,
196
+ lastSeen: occurredAt,
197
+ }),
198
+ },
199
+ occurredAt,
200
+ idempotencyKey: `discord:join:${d.guild_id}:${d.user.id}`,
201
+ };
202
+ return event;
203
+ }
204
+ case "PRESENCE_UPDATE": {
205
+ const d = envelope.d as DiscordPresenceUpdate;
206
+ if (!d.user?.id || d.status === "offline" || d.status === undefined) {
207
+ return null;
208
+ }
209
+ const occurredAt = new Date();
210
+ const event: DiscordIngestEvent = {
211
+ event: DiscordEvents.PRESENCE_UPDATE,
212
+ userId: discordUserKey(d.user.id),
213
+ discordId: d.user.id,
214
+ eventProperties: {
215
+ source: "discord",
216
+ guildId: d.guild_id ?? null,
217
+ status: d.status,
218
+ },
219
+ contactProperties: {
220
+ discord: discordMetadata({ id: d.user.id, lastSeen: occurredAt }),
221
+ },
222
+ occurredAt,
223
+ idempotencyKey:
224
+ `discord:presence:${d.user.id}:` +
225
+ `${Math.floor(occurredAt.getTime() / 60_000)}`,
226
+ };
227
+ return event;
228
+ }
229
+ default:
230
+ ctx.logger.debug("discord connector: unmapped dispatch", {
231
+ dispatch: envelope.__t,
232
+ });
233
+ return null;
234
+ }
235
+ },
236
+ });
237
+
238
+ /**
239
+ * Config for {@link createDiscordConnector} — the boot-time factory that
240
+ * populates the generic-route handlers (`oauthCallback` + `interactions`) with
241
+ * the env the plugin must NOT read directly. The consumer injects the engine's
242
+ * public credential/identity helpers as `saveDerived`/`resolveContact` so the
243
+ * plugin stays free of engine internals.
244
+ */
245
+ export interface CreateDiscordConnectorConfig {
246
+ applicationId: string;
247
+ clientSecret: string;
248
+ publicKeyHex: string;
249
+ /** …/v1/connectors/discord/oauth/callback (without the `flow` query). */
250
+ redirectUri: string;
251
+ /** Persist server-derived Discord config (kind="derived"). */
252
+ saveDerived: (patch: Record<string, unknown>) => Promise<void>;
253
+ /**
254
+ * Resolve / merge the member-linked contact (the consumer wires this to the
255
+ * engine's `resolveOrCreateContact`). The ONLY correct wiring is
256
+ * `resolveOrCreateContact({ discordId: patch.discordId, email: patch.email,
257
+ * contactProperties: patch.contactProperties })` — routing the raw snowflake
258
+ * through the `discord` identity Kind so the `discord_id` column is
259
+ * load-bearing. `email` is the AUTHORITATIVE address the link was issued for
260
+ * (from the engine-verified state), NOT the OAuth-reported Discord email.
261
+ */
262
+ resolveContact: (patch: {
263
+ discordId: string;
264
+ email?: string;
265
+ contactId?: string;
266
+ contactProperties: Record<string, unknown>;
267
+ }) => Promise<void>;
268
+ /**
269
+ * Mint a single-use `/link` code for `(discordUserId, email)`. The CONSUMER
270
+ * wires this to the engine's `createLinkCode({ db, connectorId: "discord", … })`
271
+ * so the anti-email-bomb throttle (per invoking user AND per target email,
272
+ * counted on mint) runs BEFORE the mint and an over-cap request returns
273
+ * `{ ok:false, reason:"throttled" }` without minting. A thrown error (DB down)
274
+ * MUST propagate so the loop fails CLOSED (no unthrottled send).
275
+ */
276
+ mintCode: (args: {
277
+ discordUserId: string;
278
+ email: string;
279
+ }) => Promise<LinkMintResult>;
280
+ /**
281
+ * Email a `/link`-minted code via Hogsend. The CONSUMER wires this to a
282
+ * TRANSACTIONAL send (`category: "transactional"`, `skipPreferenceCheck: true`)
283
+ * so a verification code is NEVER dropped by unsubscribe/frequency suppression
284
+ * (routing it through the journey-category `sendEmail` would silently drop it
285
+ * for unsubscribed users — see the spec's transactional-bypass correction).
286
+ */
287
+ sendLinkCode: (args: { email: string; code: string }) => Promise<void>;
288
+ /**
289
+ * Redeem a `/verify` code for the bound email. The CONSUMER wires this to the
290
+ * engine's `redeemLinkCode({ db, connectorId: "discord", platformUserId, code })`
291
+ * — single-use (atomic claim), TTL-enforced, and identity-bound (the engine
292
+ * re-checks `platformUserId` with a constant-time compare).
293
+ */
294
+ redeemCode: (args: {
295
+ discordUserId: string;
296
+ code: string;
297
+ }) => Promise<LinkRedeemResult>;
298
+ /**
299
+ * OPTIONAL anti-guessing throttle for `/verify`, checked BEFORE redeem (caps
300
+ * brute-force `/verify` traffic per Discord user). BEST-EFFORT, fail-OPEN: a
301
+ * throttle-store outage MUST NOT block a legitimate redeem — the per-mint caps
302
+ * + the redeem identity-binding are the real backstops, so a missed throttle
303
+ * never enables cross-account guessing. Omit to apply no per-attempt cap
304
+ * (redeem is still single-use + identity-bound).
305
+ */
306
+ recordVerifyAttempt?: (args: {
307
+ discordUserId: string;
308
+ }) => Promise<VerifyAttemptResult>;
309
+ /** Where to send the browser after a successful install/link. */
310
+ studioIntegrationsUrl: string;
311
+ }
312
+
313
+ /**
314
+ * The connector type the route dispatch sees — the base connector WITH
315
+ * `handlers` GUARANTEED present (the bare `discordConnector` has none, so the
316
+ * route's `connector.handlers?.oauthCallback` must type-narrow against a value
317
+ * that advertises them).
318
+ */
319
+ export type DiscordConnectorWithHandlers = DefinedConnector & {
320
+ handlers: Required<ConnectorHandlers>;
321
+ };
322
+
323
+ /**
324
+ * Build a connect-ready Discord connector: a clone of {@link discordConnector}
325
+ * with `handlers.oauthCallback` + `handlers.interactions` populated from the
326
+ * injected config. Returned as a `DefinedConnector` whose type advertises
327
+ * `handlers` so the generic engine routes dispatch into it.
328
+ */
329
+ export function createDiscordConnector(
330
+ config: CreateDiscordConnectorConfig,
331
+ ): DiscordConnectorWithHandlers {
332
+ const handlers: Required<ConnectorHandlers> = {
333
+ async interactions(args) {
334
+ const signatureHex = args.headers["x-signature-ed25519"] ?? "";
335
+ const timestamp = args.headers["x-signature-timestamp"] ?? "";
336
+ const ok = verifyInteractionSignature({
337
+ publicKeyHex: config.publicKeyHex,
338
+ signatureHex,
339
+ timestamp,
340
+ rawBody: args.rawBody,
341
+ });
342
+ if (!ok) return { kind: "unauthorized" };
343
+
344
+ let payload: Parameters<typeof handleInteraction>[0];
345
+ try {
346
+ payload = JSON.parse(args.rawBody) as Parameters<
347
+ typeof handleInteraction
348
+ >[0];
349
+ } catch {
350
+ return { kind: "unauthorized" };
351
+ }
352
+
353
+ // The ed25519 verify above gates EVERYTHING. handleInteraction runs the
354
+ // /link→/verify identify loop (PING→PONG + unknown commands fall through
355
+ // to a deferred ack). The route 200s the returned body verbatim — which IS
356
+ // Discord's interaction response (an immediate ephemeral reply for
357
+ // /verify; a type-5 ephemeral ack for /link, whose real work + @original
358
+ // PATCH happen out of band inside handleInteraction).
359
+ const response = await handleInteraction(payload, {
360
+ applicationId: config.applicationId,
361
+ mintCode: config.mintCode,
362
+ sendLinkCode: config.sendLinkCode,
363
+ redeemCode: config.redeemCode,
364
+ recordVerifyAttempt: config.recordVerifyAttempt,
365
+ resolveContact: (patch) =>
366
+ config.resolveContact({
367
+ discordId: patch.discordId,
368
+ email: patch.email,
369
+ // The /verify attach is identity-only; richer Discord metadata
370
+ // arrives via the gateway events. resolveOrCreateContact needs only
371
+ // discordId + email to bind the identity.
372
+ contactProperties: {},
373
+ }),
374
+ logger: args.ctx.logger,
375
+ });
376
+ return { kind: "ack", body: response };
377
+ },
378
+
379
+ async oauthCallback(args) {
380
+ const code = args.query.code;
381
+ // The ENGINE already verified `state` (CSRF + member-link binding) before
382
+ // dispatch and hands the decoded intent in as `args.state`. The plugin
383
+ // does NOT re-verify it. Still fail closed if Discord returned no code.
384
+ const intent = args.state;
385
+ if (!code) {
386
+ return {
387
+ kind: "json",
388
+ status: 400,
389
+ body: { error: "missing_oauth_code" },
390
+ };
391
+ }
392
+
393
+ if (intent.purpose === "install") {
394
+ // CSRF-only — no contact binding. Capture the granted guild id. The
395
+ // exchange `redirect_uri` byte-matches the bare authorize redirect (no
396
+ // `flow` query — the signed-state `purpose` already disambiguated).
397
+ const token = await exchangeDiscordCode({
398
+ applicationId: config.applicationId,
399
+ clientSecret: config.clientSecret,
400
+ code,
401
+ redirectUri: config.redirectUri,
402
+ });
403
+ await config.saveDerived({
404
+ ...(token.guild?.id ? { discordGuildId: token.guild.id } : {}),
405
+ });
406
+ // Install is the OPERATOR flow — keep redirecting to Studio.
407
+ return { kind: "redirect", location: config.studioIntegrationsUrl };
408
+ } else if (intent.purpose === "member_link") {
409
+ // Attach the Discord identity to the BOUND contact. The authoritative
410
+ // email is `intent.email` (the address the link was ISSUED for), NOT
411
+ // the OAuth-reported `user.email` — using the latter as a resolution
412
+ // key is the grafting vector. Exchange `redirect_uri` byte-matches the
413
+ // bare authorize redirect (no `flow` query).
414
+ const token = await exchangeDiscordCode({
415
+ applicationId: config.applicationId,
416
+ clientSecret: config.clientSecret,
417
+ code,
418
+ redirectUri: config.redirectUri,
419
+ });
420
+ const user: DiscordCurrentUser = await getCurrentUser(
421
+ token.access_token,
422
+ );
423
+ const patch = memberLinkToContactPatch({ user });
424
+ await config.resolveContact({
425
+ discordId: patch.discordId,
426
+ email: intent.email,
427
+ contactId: intent.contactId,
428
+ contactProperties: patch.contactProperties,
429
+ });
430
+ // Member-link is the END-USER flow — never land them in Studio. Serve a
431
+ // self-contained branded success page instead.
432
+ return {
433
+ kind: "html",
434
+ status: 200,
435
+ body: linkSuccessPage(intent.email ?? ""),
436
+ };
437
+ }
438
+
439
+ // Exhaustive: any other purpose is unsupported by this connector — never
440
+ // exchange the code (no silent fall-through into the member-link path).
441
+ return {
442
+ kind: "json",
443
+ status: 400,
444
+ body: { error: "unsupported_oauth_purpose" },
445
+ };
446
+ },
447
+ };
448
+
449
+ return defineConnector({
450
+ meta: discordConnector.meta,
451
+ credential: discordConnector.credential,
452
+ schema: discordConnector.schema,
453
+ transform: discordConnector.transform,
454
+ handlers,
455
+ }) as DiscordConnectorWithHandlers;
456
+ }
457
+
458
+ /**
459
+ * Minimal HTML escape for the ONE interpolated value (the linked email). The
460
+ * email rides in a server-minted + server-verified signed state, so it is not
461
+ * attacker-controlled today — but escaping it keeps the served page XSS-safe if
462
+ * state-minting ever loosens. Covers the five HTML-significant characters.
463
+ */
464
+ function escapeHtml(value: string): string {
465
+ return value
466
+ .replace(/&/g, "&amp;")
467
+ .replace(/</g, "&lt;")
468
+ .replace(/>/g, "&gt;")
469
+ .replace(/"/g, "&quot;")
470
+ .replace(/'/g, "&#39;");
471
+ }
472
+
473
+ /**
474
+ * The branded OAuth-fallback success page served to END-USERS after a
475
+ * member-link callback (NEVER a Studio redirect — Studio is operator-only). A
476
+ * self-contained dark page (no external assets), `noindex`. When `email` is
477
+ * empty (the signed state carried none) it renders a generic success line.
478
+ */
479
+ function linkSuccessPage(email: string): string {
480
+ const safeEmail = escapeHtml(email);
481
+ const linkedLine = safeEmail
482
+ ? `We linked <strong>${safeEmail}</strong> to your Discord account.`
483
+ : "We linked your email to your Discord account.";
484
+ return `<!doctype html>
485
+ <html lang="en">
486
+ <head>
487
+ <meta charset="utf-8" />
488
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
489
+ <meta name="robots" content="noindex" />
490
+ <title>Discord linked</title>
491
+ <style>
492
+ :root { color-scheme: dark; }
493
+ * { box-sizing: border-box; }
494
+ body {
495
+ margin: 0;
496
+ min-height: 100vh;
497
+ display: flex;
498
+ align-items: center;
499
+ justify-content: center;
500
+ background: #09090b;
501
+ color: #fafafa;
502
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
503
+ Roboto, Helvetica, Arial, sans-serif;
504
+ padding: 24px;
505
+ }
506
+ .card {
507
+ max-width: 440px;
508
+ width: 100%;
509
+ text-align: center;
510
+ background: rgba(255, 255, 255, 0.04);
511
+ border: 1px solid rgba(255, 255, 255, 0.1);
512
+ border-radius: 16px;
513
+ padding: 40px 32px;
514
+ }
515
+ .badge {
516
+ width: 56px;
517
+ height: 56px;
518
+ margin: 0 auto 20px;
519
+ border-radius: 9999px;
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: center;
523
+ background: rgba(88, 101, 242, 0.15);
524
+ color: #818cf8;
525
+ font-size: 28px;
526
+ }
527
+ h1 { font-size: 20px; margin: 0 0 8px; font-weight: 600; }
528
+ p { margin: 0; color: rgba(250, 250, 250, 0.7); line-height: 1.6; }
529
+ .hint { margin-top: 16px; font-size: 13px; color: rgba(250, 250, 250, 0.5); }
530
+ </style>
531
+ </head>
532
+ <body>
533
+ <main class="card">
534
+ <div class="badge" aria-hidden="true">&#10003;</div>
535
+ <h1>You're all set</h1>
536
+ <p>${linkedLine}</p>
537
+ <p class="hint">You can close this tab and head back to Discord.</p>
538
+ </main>
539
+ </body>
540
+ </html>`;
541
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Discord integration constants — the provider id (keys both the inbound
3
+ * connector and the outbound destination so they read as one integration),
4
+ * the Gateway intent bitfield, the two OAuth scope sets, and the API base.
5
+ * Pure data, zero `discord.js` — safe to import from the engine API process.
6
+ */
7
+
8
+ export const DISCORD_PROVIDER_ID = "discord" as const;
9
+
10
+ /**
11
+ * Gateway intent bits (`<< n` matches Discord's documented bitfield). The three
12
+ * marked `privileged` must be toggled ON in the Developer Portal AND requested
13
+ * here; without them the Gateway connection is rejected.
14
+ */
15
+ export const DISCORD_INTENTS = {
16
+ GUILDS: 1 << 0,
17
+ GUILD_MEMBERS: 1 << 1, // privileged
18
+ GUILD_PRESENCES: 1 << 8, // privileged
19
+ GUILD_MESSAGES: 1 << 9,
20
+ GUILD_MESSAGE_REACTIONS: 1 << 10,
21
+ MESSAGE_CONTENT: 1 << 15, // privileged
22
+ } as const;
23
+
24
+ /** Scopes for the one-click BOT INSTALL (adds the bot to a guild). */
25
+ export const DISCORD_BOT_INSTALL_SCOPES = [
26
+ "bot",
27
+ "applications.commands",
28
+ ] as const;
29
+
30
+ /** Scopes for the PER-MEMBER LINK (identify + verified email + membership). */
31
+ export const DISCORD_MEMBER_LINK_SCOPES = [
32
+ "identify",
33
+ "email",
34
+ "guilds.members.read",
35
+ ] as const;
36
+
37
+ export const DISCORD_API_BASE = "https://discord.com/api/v10";
38
+
39
+ /** Discord's authorize endpoint — where bot-install / member-link links point. */
40
+ export const DISCORD_OAUTH_AUTHORIZE_URL =
41
+ "https://discord.com/api/oauth2/authorize";
42
+
43
+ /** Discord's token endpoint — where the authorization code is exchanged. */
44
+ export const DISCORD_OAUTH_TOKEN_URL = "https://discord.com/api/oauth2/token";
45
+
46
+ /** Discord's PUBLIC epoch (ms) — snowflakes encode (ms - this) in bits 22+. */
47
+ export const DISCORD_EPOCH = 1420070400000n;
@@ -0,0 +1,95 @@
1
+ import {
2
+ type DestinationTransformResult,
3
+ defineDestination,
4
+ WEBHOOK_EVENT_TYPES,
5
+ } from "@hogsend/engine";
6
+ import { DISCORD_API_BASE, DISCORD_PROVIDER_ID } from "./constants.js";
7
+
8
+ /**
9
+ * Discord OUTBOUND destination config (read off `webhook_endpoints.config`).
10
+ * Prefer the no-bot-token incoming webhook; bot-REST is the alt.
11
+ */
12
+ interface DiscordDestinationConfig {
13
+ /** Discord incoming-webhook URL (`https://discord.com/api/webhooks/…`). */
14
+ webhookUrl?: string;
15
+ /** Bot-REST channel id (used with `endpoint.secret` = the bot token). */
16
+ channelId?: string;
17
+ /** Optional username override (incoming-webhook only). */
18
+ username?: string;
19
+ }
20
+
21
+ /** A compact, human-readable line per catalog event (Discord markdown). */
22
+ function formatLine(type: string, data: Record<string, unknown>): string {
23
+ const who =
24
+ (typeof data.to === "string" && data.to) ||
25
+ (typeof data.userEmail === "string" && data.userEmail) ||
26
+ undefined;
27
+ const tmpl =
28
+ typeof data.templateKey === "string" ? data.templateKey : undefined;
29
+ const parts = [`**${type}**`];
30
+ if (who) parts.push(`for \`${who}\``);
31
+ if (tmpl) parts.push(`(template \`${tmpl}\`)`);
32
+ return parts.join(" ");
33
+ }
34
+
35
+ /**
36
+ * Discord destination — posts a message per lifecycle event to a Discord
37
+ * channel. Same `meta.id = "discord"` as the inbound connector so the two faces
38
+ * read as ONE integration.
39
+ *
40
+ * Wire resolution (preferred first):
41
+ * 1. `config.webhookUrl` (or `endpoint.url` when it is a discord webhook URL)
42
+ * → POST the incoming webhook. No bot token needed. Returns `204` on
43
+ * success, so the success classifier also accepts 204.
44
+ * 2. `config.channelId` + `endpoint.secret` (bot token) → bot-REST
45
+ * `POST /channels/:id/messages` with `Authorization: Bot <token>`.
46
+ * 3. Neither → THROW (non-retryable config error → DLQ).
47
+ */
48
+ export const discordDestination = defineDestination({
49
+ meta: {
50
+ id: DISCORD_PROVIDER_ID,
51
+ name: "Discord",
52
+ description:
53
+ "Post a message per lifecycle event to a Discord channel — incoming " +
54
+ "webhook (no bot token) preferred, bot-REST as the alt.",
55
+ },
56
+ events: [...WEBHOOK_EVENT_TYPES],
57
+ transform(envelope, ctx) {
58
+ const config = (ctx.endpoint.config ?? {}) as DiscordDestinationConfig;
59
+ const content = formatLine(envelope.type, envelope.data);
60
+
61
+ const webhookUrl = config.webhookUrl ?? ctx.endpoint.url;
62
+ if (webhookUrl?.startsWith("https://discord.com/api/webhooks/")) {
63
+ const result: DestinationTransformResult = {
64
+ url: webhookUrl,
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({
68
+ content,
69
+ ...(config.username ? { username: config.username } : {}),
70
+ }),
71
+ isSuccess: (status) =>
72
+ status === 204 || (status >= 200 && status < 300),
73
+ };
74
+ return result;
75
+ }
76
+
77
+ if (config.channelId && ctx.endpoint.secret) {
78
+ const result: DestinationTransformResult = {
79
+ url: `${DISCORD_API_BASE}/channels/${config.channelId}/messages`,
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ Authorization: `Bot ${ctx.endpoint.secret}`,
84
+ },
85
+ body: JSON.stringify({ content }),
86
+ };
87
+ return result;
88
+ }
89
+
90
+ throw new Error(
91
+ "discord destination needs config.webhookUrl (preferred) OR " +
92
+ "config.channelId + endpoint.secret (bot token)",
93
+ );
94
+ },
95
+ });