@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
package/src/connector.ts
ADDED
|
@@ -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, "&")
|
|
467
|
+
.replace(/</g, "<")
|
|
468
|
+
.replace(/>/g, ">")
|
|
469
|
+
.replace(/"/g, """)
|
|
470
|
+
.replace(/'/g, "'");
|
|
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">✓</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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
});
|