@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/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
|
+
});
|