@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 ADDED
@@ -0,0 +1,93 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ Copyright 2025 Doug Silkstone and Hogsend contributors
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject to
14
+ the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set of
20
+ the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
27
+ of the licensor in the software. Any use of the licensor's trademarks is subject
28
+ to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license for
39
+ the software granted under these terms ends immediately. If your company makes
40
+ such a claim, your patent license ends immediately for work on behalf of your
41
+ company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from you
46
+ also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license no
61
+ later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your licenses
64
+ to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ *As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising out
70
+ of these terms or the use or nature of the software, under any kind of legal
71
+ claim.*
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is the
76
+ software the licensor makes available under these terms, including any portion
77
+ of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or the
85
+ power to direct its management and policies by vote, contract, or otherwise.
86
+ Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your licenses.
92
+
93
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # @hogsend/plugin-discord
2
+
3
+ Discord integration for Hogsend — both faces of one platform:
4
+
5
+ - **Inbound** — a `transport: "gateway"` connector (`discordConnector`) that
6
+ turns raw Discord Gateway dispatches (messages, reactions, joins, presence)
7
+ into `IngestEvent`s. The socket itself lives in a separate long-lived worker
8
+ (`@hogsend/plugin-discord/gateway`) that POSTs each dispatch to the connector
9
+ ingress (`POST /v1/connectors/discord/ingress`) so all transform logic stays
10
+ server-side.
11
+ - **Outbound** — a `defineDestination` (`discordDestination`) that posts a
12
+ message per lifecycle event to a Discord channel (incoming webhook preferred,
13
+ bot-REST as the alt).
14
+
15
+ Same `meta.id = "discord"` on both so they read as one integration.
16
+
17
+ Full setup and the four event mappings live in the
18
+ [Discord integration docs](https://hogsend.com/docs/integrations/discord).
19
+
20
+ ## Inbound events
21
+
22
+ The server-side connector transform emits these into `ingestEvent()` (stored in
23
+ `user_events` + upserts a contact). Bot/webhook/system messages and
24
+ offline/absent presence are dropped; each event carries a deterministic
25
+ `idempotencyKey` so redelivery dedupes.
26
+
27
+ | Discord dispatch | Hogsend event |
28
+ | ---------------------- | -------------------------- |
29
+ | `MESSAGE_CREATE` | `discord.message_sent` |
30
+ | `MESSAGE_REACTION_ADD` | `discord.reaction_added` |
31
+ | `GUILD_MEMBER_ADD` | `discord.member_joined` |
32
+ | `PRESENCE_UPDATE` | `discord.presence_active` |
33
+
34
+ ## Identity
35
+
36
+ `contacts.discord_id` is a 4th contact identity Kind
37
+ (`external | email | anonymous | discord`) — the raw snowflake is the indexed
38
+ merge key (partial unique index). The connector also writes
39
+ `contacts.properties.discord` (deep-merged one level, non-clobbering): `id` and
40
+ `last_seen` always, plus conditional `username`, `global_name`, `avatar`,
41
+ `joined_at`, and `roles`. `null` is never written.
42
+
43
+ `last_seen` is DERIVED first-party (the max of observed event timestamps —
44
+ Discord has no last-seen field). Presence is collapsed to "active" (offline and
45
+ absent are dropped), so presence is not a last-seen feed.
46
+
47
+ ## The `/link` identity loop
48
+
49
+ `/link` (no options) opens an email modal. A valid address mails a 6-digit code
50
+ via a transactional template; an "Enter code" button then opens a code modal
51
+ that redeems it and resolves the contact (the button is the mandatory bridge —
52
+ Discord forbids returning a modal from a modal submit). Every step is ephemeral;
53
+ no message body echoes the email or code. `/verify <code>` is the typed
54
+ fallback.
55
+
56
+ Codes are single-use (atomic claim), have a 15-min TTL, are bound to the
57
+ invoking Discord user (constant-time compare), and are hashed at rest. Throttles:
58
+ 5 mints/user + 3 mints/email per 15-min window (engine); an optional consumer
59
+ Redis throttle of 10 `/verify` attempts/user/15 min (fail-open). Every
60
+ interaction is ed25519-verified (native `node:crypto`, fail-closed) with a ±300s
61
+ timestamp replay window.
62
+
63
+ ## Routes
64
+
65
+ The engine mounts these under `/v1/connectors/discord`:
66
+
67
+ | Route | Purpose | Auth |
68
+ | ------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ |
69
+ | `POST /v1/connectors/discord/ingress` | Gateway worker posts raw dispatches | `x-hogsend-ingress-secret` header (= `CONNECTOR_INGRESS_SECRET`, ≥32 chars, fail-closed) |
70
+ | `POST /v1/connectors/discord/interactions` | Discord HTTP interactions (slash/modal/button) | ed25519 signature + ±300s replay window |
71
+ | `GET\|POST /v1/connectors/discord/oauth/callback` | OAuth install + member-link (not wired in apps/api) | signed CSRF `state`, engine-verified |
72
+
73
+ `/v1/connectors/*` is per-IP rate-limited (60/min) EXCEPT `/ingress` and
74
+ `/interactions` (exempt — gated by the ingress secret and ed25519+replay).
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pnpm add @hogsend/plugin-discord
80
+ # the Gateway worker needs discord.js (an optional peer):
81
+ pnpm add discord.js
82
+ ```
83
+
84
+ `discord.js` is an **optional peer** — only the `/gateway` subpath imports it.
85
+ The engine API process imports `discordConnector` / `discordDestination` /
86
+ connect helpers from the main entry and never loads a WebSocket client.
87
+
88
+ ## Wiring (consumer)
89
+
90
+ All callbacks below are required (only `recordVerifyAttempt` is optional). The
91
+ consumer injects the engine helpers so the plugin never reaches into the engine
92
+ itself — see `apps/api/src/discord.ts` for the full reference wiring.
93
+
94
+ ```ts
95
+ import {
96
+ createLinkCode,
97
+ getDerivedCredential,
98
+ getEmailService,
99
+ redeemLinkCode,
100
+ resolveOrCreateContact,
101
+ saveDerivedCredential,
102
+ } from "@hogsend/engine";
103
+ import {
104
+ createDiscordConnector,
105
+ discordDestination,
106
+ } from "@hogsend/plugin-discord";
107
+
108
+ const base = env.API_PUBLIC_URL.replace(/\/$/, "");
109
+
110
+ const discord = createDiscordConnector({
111
+ applicationId: env.DISCORD_APPLICATION_ID,
112
+ clientSecret: env.DISCORD_CLIENT_SECRET,
113
+ publicKeyHex: env.DISCORD_PUBLIC_KEY,
114
+ redirectUri: `${base}/v1/connectors/discord/oauth/callback`,
115
+ // Studio's SPA is mounted at /studio, so its integrations page lives at
116
+ // /studio/integrations (NOT /integrations, which 404s at the API root).
117
+ studioIntegrationsUrl: `${base}/studio/integrations`,
118
+ // Read-merge-write — the derived store is a full-payload OVERWRITE.
119
+ saveDerived: async (patch) => {
120
+ const current = (await getDerivedCredential(db, "discord")) ?? {};
121
+ await saveDerivedCredential(db, "discord", { ...current, ...patch });
122
+ },
123
+ // Route the snowflake through the `discord` identity Kind; `email` is the
124
+ // engine-verified address the link was issued for (never the OAuth email).
125
+ resolveContact: async (patch) => {
126
+ await resolveOrCreateContact({
127
+ db,
128
+ discordId: patch.discordId,
129
+ email: patch.email,
130
+ contactProperties: patch.contactProperties,
131
+ });
132
+ },
133
+ // Mint a single-use code — the anti-email-bomb throttle runs FIRST inside
134
+ // createLinkCode; over-cap returns { ok: false } with no mint.
135
+ mintCode: async ({ discordUserId, email }) => {
136
+ const r = await createLinkCode({
137
+ db,
138
+ connectorId: "discord",
139
+ platformUserId: discordUserId,
140
+ email,
141
+ });
142
+ return r.ok ? { ok: true, code: r.code } : { ok: false, reason: "throttled" };
143
+ },
144
+ // TRANSACTIONAL send — skipPreferenceCheck so a code is NEVER dropped by an
145
+ // unsubscribe or frequency cap.
146
+ sendLinkCode: async ({ email, code }) => {
147
+ await getEmailService().send({
148
+ template: "transactional/discord-link-code",
149
+ props: { code },
150
+ to: email,
151
+ userId: email,
152
+ userEmail: email,
153
+ subject: "Your Discord verification code",
154
+ category: "transactional",
155
+ skipPreferenceCheck: true,
156
+ });
157
+ },
158
+ // Redeem — single-use (atomic claim), TTL-enforced, identity-bound.
159
+ redeemCode: ({ discordUserId, code }) =>
160
+ redeemLinkCode({
161
+ db,
162
+ connectorId: "discord",
163
+ platformUserId: discordUserId,
164
+ code,
165
+ }),
166
+ });
167
+
168
+ const client = createHogsendClient({
169
+ connectors: [discord],
170
+ destinations: [discordDestination],
171
+ });
172
+ ```
173
+
174
+ ## Gateway worker
175
+
176
+ A separate Railway service (mirrors `railway.worker.toml`):
177
+
178
+ ```ts
179
+ import { createDiscordGatewayWorker } from "@hogsend/plugin-discord/gateway";
180
+
181
+ const worker = createDiscordGatewayWorker({
182
+ botToken: process.env.DISCORD_BOT_TOKEN!,
183
+ apiPublicUrl: process.env.API_PUBLIC_URL!,
184
+ ingressSecret: process.env.CONNECTOR_INGRESS_SECRET!,
185
+ });
186
+ process.on("SIGTERM", () => void worker.stop());
187
+ process.on("SIGINT", () => void worker.stop());
188
+ await worker.start();
189
+ ```
190
+
191
+ `start()` dynamically imports `discord.js`, logs in with the bot token, and
192
+ forwards every raw Gateway dispatch to the ingress via `forwardDispatch`
193
+ (`{ __t, d }` wrapping over `postToIngress`). It fails loudly: `login()` rejects
194
+ (and the rejection propagates out of `start()`) on a bad token or a requested
195
+ privileged intent that is not toggled in the portal, so a misconfigured worker
196
+ is never silently dead. discord.js owns heartbeat / RESUME / reconnect /
197
+ sharding.
198
+
199
+ ## Secrets & rotation
200
+
201
+ Discord app secrets are held in **two places**:
202
+
203
+ 1. Encrypted in `provider_credentials` (kind `derived`, providerId `discord`)
204
+ for the API-side connect helpers — written by `hogsend connect discord`.
205
+ 2. Plain env on the deployed Gateway worker (`DISCORD_BOT_TOKEN`) so it can log
206
+ in without a DB round-trip at boot.
207
+
208
+ **Rotation runbook** — rotate the bot token in the Discord Developer Portal,
209
+ then:
210
+
211
+ 1. Re-run `hogsend connect discord` (re-paste the new token) to update the
212
+ encrypted derived store, **and**
213
+ 2. Update `DISCORD_BOT_TOKEN` on the Gateway worker service and redeploy it.
214
+
215
+ Both copies point at the same token; updating only one leaves them drifted.
216
+
217
+ ## Required intents
218
+
219
+ Toggle ON in the Developer Portal (Bot → Privileged Gateway Intents):
220
+ `SERVER MEMBERS`, `PRESENCE`, and `MESSAGE CONTENT`. Without them the Gateway
221
+ connection is rejected and message text is empty (`hasContent` reports it).
222
+ Under 10k users / 100 guilds these three are a self-serve portal toggle (no
223
+ Discord review). Each self-hosted deploy runs its own Discord app (single
224
+ tenant).
225
+
226
+ ## Caveats
227
+
228
+ - The bot must be a guild member to receive a channel's events.
229
+ - Presence is not last-seen (offline/absent dropped; `last_seen` is derived).
230
+ - The one-click install + OAuth member-link (`hogsend connect discord`) is NOT
231
+ wired in `apps/api` yet — the consumer-mounted `secrets`/`wire` admin routes
232
+ are unmounted, so that CLI 404s today. Use the env-only inbound path
233
+ (Gateway → ingress) and the modal `/link` for identity.
234
+ - First npm publish of this package is MANUAL — CI cannot create a brand-new
235
+ `@hogsend/*` package.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@hogsend/plugin-discord",
3
+ "version": "0.22.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dougwithseismic/hogsend.git",
9
+ "directory": "packages/plugin-discord"
10
+ },
11
+ "sideEffects": false,
12
+ "main": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./gateway": "./src/gateway/index.ts"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@hogsend/engine": "^0.22.0"
27
+ },
28
+ "peerDependencies": {
29
+ "discord.js": ">=14.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "discord.js": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "discord.js": "^14.26.4",
39
+ "tsup": "^8.0.0",
40
+ "typescript": "^5.7.0",
41
+ "vitest": "^4.1.7",
42
+ "@repo/typescript-config": "^0.0.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "check-types": "tsc --noEmit",
47
+ "test": "vitest run --passWithNoTests",
48
+ "test:watch": "vitest watch"
49
+ }
50
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { discordConnector } from "../connector.js";
3
+ import { DiscordEvents } from "../events.js";
4
+
5
+ /**
6
+ * The connector transform is pure (no db/network touched), so the ctx is a
7
+ * stub: a no-op logger + a cast db. `transport: "gateway"` matches the ingress.
8
+ */
9
+ const logger = {
10
+ debug: vi.fn(),
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ };
15
+ const ctx = {
16
+ db: {} as never,
17
+ logger: logger as never,
18
+ transport: "gateway" as const,
19
+ };
20
+
21
+ function wrap(t: keyof typeof DiscordEvents, d: unknown) {
22
+ return { __t: t, d };
23
+ }
24
+
25
+ describe("discordConnector.transform", () => {
26
+ it("maps MESSAGE_CREATE → discord.message_sent with derived lastSeen", async () => {
27
+ const event = await discordConnector.transform(
28
+ wrap("MESSAGE_CREATE", {
29
+ // snowflake encoding a known epoch-ish timestamp
30
+ id: "175928847299117063",
31
+ channel_id: "c1",
32
+ guild_id: "g1",
33
+ content: "hi there",
34
+ author: { id: "u1", username: "alice" },
35
+ }),
36
+ ctx,
37
+ );
38
+ expect(event).not.toBeNull();
39
+ expect(event?.event).toBe(DiscordEvents.MESSAGE_CREATE);
40
+ expect(event?.userId).toBe("discord:u1");
41
+ // discordId is the snowflake (forward-compat IngestEvent field)
42
+ expect((event as { discordId?: string })?.discordId).toBe("u1");
43
+ expect(event?.eventProperties.hasContent).toBe(true);
44
+ expect(event?.eventProperties.guildId).toBe("g1");
45
+ // Nested NON-KEY metadata under contactProperties.discord (deep-merged
46
+ // engine-side); discord_id stays the sole identity key.
47
+ const meta = event?.contactProperties?.discord as Record<string, unknown>;
48
+ expect(meta.id).toBe("u1");
49
+ expect(meta.username).toBe("alice");
50
+ expect(meta.last_seen).toBeTypeOf("string");
51
+ expect(event?.idempotencyKey).toBe("discord:msg:175928847299117063");
52
+ // occurredAt derived from the snowflake (not "now")
53
+ expect(event?.occurredAt).toBeInstanceOf(Date);
54
+ });
55
+
56
+ it("hasContent=false when MESSAGE_CONTENT intent yields empty text", async () => {
57
+ const event = await discordConnector.transform(
58
+ wrap("MESSAGE_CREATE", {
59
+ id: "175928847299117064",
60
+ channel_id: "c1",
61
+ content: "",
62
+ author: { id: "u2" },
63
+ }),
64
+ ctx,
65
+ );
66
+ expect(event?.eventProperties.hasContent).toBe(false);
67
+ });
68
+
69
+ it("drops bot and webhook messages (returns null)", async () => {
70
+ const bot = await discordConnector.transform(
71
+ wrap("MESSAGE_CREATE", {
72
+ id: "1",
73
+ channel_id: "c1",
74
+ author: { id: "b1", bot: true },
75
+ }),
76
+ ctx,
77
+ );
78
+ const hook = await discordConnector.transform(
79
+ wrap("MESSAGE_CREATE", {
80
+ id: "2",
81
+ channel_id: "c1",
82
+ webhook_id: "w1",
83
+ author: { id: "x1" },
84
+ }),
85
+ ctx,
86
+ );
87
+ expect(bot).toBeNull();
88
+ expect(hook).toBeNull();
89
+ });
90
+
91
+ it("maps MESSAGE_REACTION_ADD with deterministic idempotency key", async () => {
92
+ const event = await discordConnector.transform(
93
+ wrap("MESSAGE_REACTION_ADD", {
94
+ user_id: "u9",
95
+ channel_id: "c2",
96
+ message_id: "m5",
97
+ guild_id: "g2",
98
+ emoji: { name: "🔥" },
99
+ }),
100
+ ctx,
101
+ );
102
+ expect(event?.event).toBe(DiscordEvents.MESSAGE_REACTION_ADD);
103
+ expect(event?.userId).toBe("discord:u9");
104
+ expect(event?.eventProperties.emoji).toBe("🔥");
105
+ expect(event?.idempotencyKey).toBe("discord:react:m5:u9:🔥");
106
+ });
107
+
108
+ it("maps GUILD_MEMBER_ADD → discord.member_joined", async () => {
109
+ const event = await discordConnector.transform(
110
+ wrap("GUILD_MEMBER_ADD", {
111
+ guild_id: "g3",
112
+ joined_at: "2026-06-13T00:00:00.000Z",
113
+ roles: ["r1", "r2"],
114
+ user: {
115
+ id: "u10",
116
+ username: "bob",
117
+ global_name: "Bob",
118
+ avatar: "abc123",
119
+ },
120
+ }),
121
+ ctx,
122
+ );
123
+ expect(event?.event).toBe(DiscordEvents.GUILD_MEMBER_ADD);
124
+ expect(event?.userId).toBe("discord:u10");
125
+ const meta = event?.contactProperties?.discord as Record<string, unknown>;
126
+ expect(meta.id).toBe("u10");
127
+ expect(meta.username).toBe("bob");
128
+ expect(meta.global_name).toBe("Bob");
129
+ expect(meta.avatar).toBe("abc123");
130
+ expect(meta.joined_at).toBe("2026-06-13T00:00:00.000Z");
131
+ expect(meta.roles).toEqual(["r1", "r2"]);
132
+ expect(event?.idempotencyKey).toBe("discord:join:g3:u10");
133
+ });
134
+
135
+ it("drops a bot GUILD_MEMBER_ADD", async () => {
136
+ const event = await discordConnector.transform(
137
+ wrap("GUILD_MEMBER_ADD", {
138
+ guild_id: "g3",
139
+ user: { id: "bot1", bot: true },
140
+ }),
141
+ ctx,
142
+ );
143
+ expect(event).toBeNull();
144
+ });
145
+
146
+ it("collapses non-offline presence to discord.presence_active", async () => {
147
+ const online = await discordConnector.transform(
148
+ wrap("PRESENCE_UPDATE", { user: { id: "u11" }, status: "online" }),
149
+ ctx,
150
+ );
151
+ expect(online?.event).toBe(DiscordEvents.PRESENCE_UPDATE);
152
+ expect(online?.eventProperties.status).toBe("online");
153
+
154
+ const offline = await discordConnector.transform(
155
+ wrap("PRESENCE_UPDATE", { user: { id: "u11" }, status: "offline" }),
156
+ ctx,
157
+ );
158
+ expect(offline).toBeNull();
159
+ });
160
+
161
+ it("logs + drops an unmapped dispatch", async () => {
162
+ const event = await discordConnector.transform(
163
+ { __t: "TYPING_START", d: {} },
164
+ ctx,
165
+ );
166
+ expect(event).toBeNull();
167
+ expect(logger.debug).toHaveBeenCalled();
168
+ });
169
+
170
+ it("declares gateway transport with a derived credential, no inboundVerify", () => {
171
+ expect(discordConnector.meta.transport).toBe("gateway");
172
+ expect(discordConnector.inboundVerify).toBeUndefined();
173
+ expect(discordConnector.credential).toEqual({
174
+ providerId: "discord",
175
+ kind: "derived",
176
+ });
177
+ });
178
+ });
@@ -0,0 +1,94 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DiscordEvents } from "../events.js";
3
+ import {
4
+ type DiscordGatewayWorkerConfig,
5
+ forwardDispatch,
6
+ } from "../gateway/worker.js";
7
+
8
+ /**
9
+ * Unit-tests the dispatch→ingress mapping WITHOUT a live `discord.js` socket:
10
+ * a fake poster is injected (the same seam `start()` calls on every raw packet),
11
+ * so we assert the pre-filter, the args handed to ingress, and the never-throws
12
+ * contract independently of the WebSocket loop.
13
+ */
14
+ const config: DiscordGatewayWorkerConfig = {
15
+ botToken: "Bot fake-token",
16
+ apiPublicUrl: "https://tunnel.example.com",
17
+ ingressSecret: "x".repeat(32),
18
+ };
19
+
20
+ let errorSpy: ReturnType<typeof vi.spyOn>;
21
+
22
+ beforeEach(() => {
23
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
24
+ });
25
+
26
+ afterEach(() => {
27
+ errorSpy.mockRestore();
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ describe("forwardDispatch", () => {
32
+ it("forwards a mapped dispatch with the exact ingress args", async () => {
33
+ const poster = vi.fn().mockResolvedValue({ ok: true, status: 200 });
34
+ const d = { id: "1", author: { id: "u1" } };
35
+
36
+ await forwardDispatch(config, { t: "MESSAGE_CREATE", d }, poster);
37
+
38
+ expect(poster).toHaveBeenCalledTimes(1);
39
+ expect(poster).toHaveBeenCalledWith({
40
+ apiPublicUrl: config.apiPublicUrl,
41
+ ingressSecret: config.ingressSecret,
42
+ dispatchType: "MESSAGE_CREATE",
43
+ data: d,
44
+ });
45
+ });
46
+
47
+ it("forwards every mapped Discord dispatch type", async () => {
48
+ const poster = vi.fn().mockResolvedValue({ ok: true, status: 200 });
49
+ for (const t of Object.keys(DiscordEvents)) {
50
+ await forwardDispatch(config, { t, d: { probe: t } }, poster);
51
+ }
52
+ expect(poster).toHaveBeenCalledTimes(Object.keys(DiscordEvents).length);
53
+ for (const t of Object.keys(DiscordEvents)) {
54
+ expect(poster).toHaveBeenCalledWith(
55
+ expect.objectContaining({ dispatchType: t }),
56
+ );
57
+ }
58
+ });
59
+
60
+ it("skips an unmapped dispatch type (cheap pre-filter, no POST)", async () => {
61
+ const poster = vi.fn().mockResolvedValue({ ok: true, status: 200 });
62
+ await forwardDispatch(config, { t: "TYPING_START", d: {} }, poster);
63
+ expect(poster).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("skips a frame with no dispatch type (op-only / heartbeat ack)", async () => {
67
+ const poster = vi.fn().mockResolvedValue({ ok: true, status: 200 });
68
+ await forwardDispatch(config, { t: null, d: undefined }, poster);
69
+ await forwardDispatch(config, {}, poster);
70
+ expect(poster).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("logs a non-2xx ingress response but does not throw", async () => {
74
+ const poster = vi.fn().mockResolvedValue({ ok: false, status: 401 });
75
+ await expect(
76
+ forwardDispatch(config, { t: "MESSAGE_CREATE", d: {} }, poster),
77
+ ).resolves.toBeUndefined();
78
+ expect(errorSpy).toHaveBeenCalledWith(
79
+ expect.stringContaining("non-2xx (401)"),
80
+ );
81
+ });
82
+
83
+ it("swallows a thrown poster error (socket stays up)", async () => {
84
+ const poster = vi.fn().mockRejectedValue(new Error("network down"));
85
+ await expect(
86
+ forwardDispatch(config, { t: "GUILD_MEMBER_ADD", d: {} }, poster),
87
+ ).resolves.toBeUndefined();
88
+ // message-only log (secret hygiene): the raw Error object is never logged.
89
+ expect(errorSpy).toHaveBeenCalledWith(
90
+ "discord ingress forward failed:",
91
+ "network down",
92
+ );
93
+ });
94
+ });