@hogsend/plugin-discord 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +93 -0
- package/README.md +235 -0
- package/package.json +50 -0
- package/src/__tests__/connector-transform.test.ts +178 -0
- package/src/__tests__/gateway-forward.test.ts +94 -0
- package/src/__tests__/interactions-followup.test.ts +94 -0
- package/src/__tests__/interactions.test.ts +585 -0
- package/src/__tests__/member-link.test.ts +58 -0
- package/src/__tests__/oauth.test.ts +214 -0
- package/src/connect/interactions-followup.ts +59 -0
- package/src/connect/interactions.ts +864 -0
- package/src/connect/member-link.ts +79 -0
- package/src/connect/oauth.ts +159 -0
- package/src/connect/patch-application.ts +67 -0
- package/src/connector.ts +541 -0
- package/src/constants.ts +47 -0
- package/src/destination.ts +95 -0
- package/src/env.ts +25 -0
- package/src/events.ts +16 -0
- package/src/gateway/index.ts +7 -0
- package/src/gateway/ingress.ts +50 -0
- package/src/gateway/worker.ts +180 -0
- package/src/index.ts +71 -0
- package/src/types.ts +61 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { memberLinkToContactPatch } from "../connect/member-link.js";
|
|
3
|
+
import type { DiscordCurrentUser } from "../connect/oauth.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The member-link reducer maps a `/users/@me` pull → a contact patch. It must:
|
|
7
|
+
* - route the snowflake through `discordId` (the sole identity KEY),
|
|
8
|
+
* - populate the NON-KEY `properties.discord` metadata object,
|
|
9
|
+
* - keep `isDiscordLinked` + the verified-only `discordEmail` as TOP-LEVEL
|
|
10
|
+
* flags (anti-graft: the Discord email is never a resolution key).
|
|
11
|
+
*/
|
|
12
|
+
describe("memberLinkToContactPatch", () => {
|
|
13
|
+
it("emits a nested discord metadata object + discordId key", () => {
|
|
14
|
+
const user: DiscordCurrentUser = {
|
|
15
|
+
id: "u1",
|
|
16
|
+
username: "alice",
|
|
17
|
+
global_name: "Alice",
|
|
18
|
+
avatar: "hash1",
|
|
19
|
+
email: "alice@example.com",
|
|
20
|
+
verified: true,
|
|
21
|
+
};
|
|
22
|
+
const patch = memberLinkToContactPatch({ user });
|
|
23
|
+
|
|
24
|
+
expect(patch.discordId).toBe("u1");
|
|
25
|
+
const discord = patch.contactProperties.discord as Record<string, unknown>;
|
|
26
|
+
expect(discord).toEqual({
|
|
27
|
+
id: "u1",
|
|
28
|
+
username: "alice",
|
|
29
|
+
global_name: "Alice",
|
|
30
|
+
avatar: "hash1",
|
|
31
|
+
});
|
|
32
|
+
// A link is an identity attach, not activity — no last_seen stamped here.
|
|
33
|
+
expect(discord.last_seen).toBeUndefined();
|
|
34
|
+
expect(patch.contactProperties.isDiscordLinked).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stores a VERIFIED Discord email as a non-key flag only", () => {
|
|
38
|
+
const patch = memberLinkToContactPatch({
|
|
39
|
+
user: { id: "u2", email: "v@example.com", verified: true },
|
|
40
|
+
});
|
|
41
|
+
expect(patch.contactProperties.discordEmail).toBe("v@example.com");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("DROPS an unverified Discord email (anti-graft)", () => {
|
|
45
|
+
const patch = memberLinkToContactPatch({
|
|
46
|
+
user: { id: "u3", email: "u@example.com", verified: false },
|
|
47
|
+
});
|
|
48
|
+
expect(patch.contactProperties.discordEmail).toBeUndefined();
|
|
49
|
+
// The id still routes through the identity key.
|
|
50
|
+
expect(patch.discordId).toBe("u3");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("omits absent optional metadata fields (no null clobber)", () => {
|
|
54
|
+
const patch = memberLinkToContactPatch({ user: { id: "u4" } });
|
|
55
|
+
const discord = patch.contactProperties.discord as Record<string, unknown>;
|
|
56
|
+
expect(discord).toEqual({ id: "u4" });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { WebhookEndpointRow } from "@hogsend/engine";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { memberLinkToContactPatch } from "../connect/member-link.js";
|
|
4
|
+
import {
|
|
5
|
+
buildBotInstallUrl,
|
|
6
|
+
buildMemberLinkUrl,
|
|
7
|
+
exchangeDiscordCode,
|
|
8
|
+
} from "../connect/oauth.js";
|
|
9
|
+
import { discordDestination } from "../destination.js";
|
|
10
|
+
|
|
11
|
+
/** Minimal endpoint row stub — only the fields the transform reads. */
|
|
12
|
+
function makeEndpoint(row: {
|
|
13
|
+
url?: string | null;
|
|
14
|
+
secret?: string | null;
|
|
15
|
+
config?: Record<string, unknown>;
|
|
16
|
+
}): WebhookEndpointRow {
|
|
17
|
+
return {
|
|
18
|
+
url: row.url ?? null,
|
|
19
|
+
secret: row.secret ?? null,
|
|
20
|
+
config: row.config ?? {},
|
|
21
|
+
} as unknown as WebhookEndpointRow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("buildBotInstallUrl", () => {
|
|
29
|
+
it("carries scope, permissions, state, and the BARE redirect (no flow)", () => {
|
|
30
|
+
const url = new URL(
|
|
31
|
+
buildBotInstallUrl({
|
|
32
|
+
applicationId: "app1",
|
|
33
|
+
redirectUri:
|
|
34
|
+
"https://api.example.com/v1/connectors/discord/oauth/callback",
|
|
35
|
+
permissions: "8",
|
|
36
|
+
state: "csrf-123",
|
|
37
|
+
guildId: "g1",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(url.searchParams.get("client_id")).toBe("app1");
|
|
41
|
+
expect(url.searchParams.get("scope")).toBe("bot applications.commands");
|
|
42
|
+
expect(url.searchParams.get("permissions")).toBe("8");
|
|
43
|
+
expect(url.searchParams.get("state")).toBe("csrf-123");
|
|
44
|
+
expect(url.searchParams.get("guild_id")).toBe("g1");
|
|
45
|
+
// The redirect is the bare callback — the signed-state `purpose` (not a
|
|
46
|
+
// `flow` query) disambiguates install vs. member, and the exchange
|
|
47
|
+
// `redirect_uri` must byte-match this value.
|
|
48
|
+
expect(url.searchParams.get("redirect_uri")).toBe(
|
|
49
|
+
"https://api.example.com/v1/connectors/discord/oauth/callback",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("buildMemberLinkUrl", () => {
|
|
55
|
+
it("requests identify+email+membership scopes with the BARE redirect", () => {
|
|
56
|
+
const url = new URL(
|
|
57
|
+
buildMemberLinkUrl({
|
|
58
|
+
applicationId: "app1",
|
|
59
|
+
redirectUri:
|
|
60
|
+
"https://api.example.com/v1/connectors/discord/oauth/callback",
|
|
61
|
+
state: "csrf-bound-to-contact",
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(url.searchParams.get("scope")).toBe(
|
|
65
|
+
"identify email guilds.members.read",
|
|
66
|
+
);
|
|
67
|
+
expect(url.searchParams.get("state")).toBe("csrf-bound-to-contact");
|
|
68
|
+
// Bare redirect — no `flow` query (signed-state `purpose` disambiguates).
|
|
69
|
+
expect(url.searchParams.get("redirect_uri")).toBe(
|
|
70
|
+
"https://api.example.com/v1/connectors/discord/oauth/callback",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("exchangeDiscordCode", () => {
|
|
76
|
+
it("POSTs the form body and returns the token (with guild on install)", async () => {
|
|
77
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
78
|
+
new Response(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
access_token: "at",
|
|
81
|
+
token_type: "Bearer",
|
|
82
|
+
expires_in: 604800,
|
|
83
|
+
scope: "bot",
|
|
84
|
+
guild: { id: "g99", name: "Test" },
|
|
85
|
+
}),
|
|
86
|
+
{ status: 200 },
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const token = await exchangeDiscordCode({
|
|
91
|
+
applicationId: "app1",
|
|
92
|
+
clientSecret: "secret",
|
|
93
|
+
code: "code123",
|
|
94
|
+
redirectUri:
|
|
95
|
+
"https://api.example.com/v1/connectors/discord/oauth/callback",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(token.access_token).toBe("at");
|
|
99
|
+
expect(token.guild?.id).toBe("g99");
|
|
100
|
+
const call = fetchSpy.mock.calls[0];
|
|
101
|
+
const init = call?.[1];
|
|
102
|
+
expect((init?.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
103
|
+
"application/x-www-form-urlencoded",
|
|
104
|
+
);
|
|
105
|
+
expect(String(init?.body)).toContain("grant_type=authorization_code");
|
|
106
|
+
expect(String(init?.body)).toContain("code=code123");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("throws on a non-2xx token response", async () => {
|
|
110
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
111
|
+
new Response("bad request", { status: 400 }),
|
|
112
|
+
);
|
|
113
|
+
await expect(
|
|
114
|
+
exchangeDiscordCode({
|
|
115
|
+
applicationId: "a",
|
|
116
|
+
clientSecret: "s",
|
|
117
|
+
code: "c",
|
|
118
|
+
redirectUri: "https://x/cb",
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow(/token exchange failed \(400\)/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("memberLinkToContactPatch", () => {
|
|
125
|
+
it("returns the RAW snowflake as discordId (no `discord:` prefix, no userId)", () => {
|
|
126
|
+
const patch = memberLinkToContactPatch({
|
|
127
|
+
user: {
|
|
128
|
+
id: "u1",
|
|
129
|
+
username: "alice",
|
|
130
|
+
email: "alice@example.com",
|
|
131
|
+
verified: true,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
expect(patch.discordId).toBe("u1");
|
|
135
|
+
// The patch is NOT a resolution-key carrier for email — the snowflake is
|
|
136
|
+
// routed through the `discord` identity Kind, never stuffed into userId.
|
|
137
|
+
expect("userId" in patch).toBe(false);
|
|
138
|
+
expect("email" in patch).toBe(false);
|
|
139
|
+
expect(patch.contactProperties.isDiscordLinked).toBe(true);
|
|
140
|
+
// Username now rides in the NON-KEY nested `discord` metadata object.
|
|
141
|
+
const discord = patch.contactProperties.discord as Record<string, unknown>;
|
|
142
|
+
expect(discord.id).toBe("u1");
|
|
143
|
+
expect(discord.username).toBe("alice");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("stores a present+verified email as a NON-KEY contactProperty (discordEmail)", () => {
|
|
147
|
+
const patch = memberLinkToContactPatch({
|
|
148
|
+
user: { id: "u1", email: "alice@example.com", verified: true },
|
|
149
|
+
});
|
|
150
|
+
// Stored as a property only — NEVER a resolution/merge key (anti-graft).
|
|
151
|
+
expect(patch.contactProperties.discordEmail).toBe("alice@example.com");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("OMITS a present-but-UNVERIFIED email entirely (anti account-takeover)", () => {
|
|
155
|
+
const patch = memberLinkToContactPatch({
|
|
156
|
+
user: { id: "u2", email: "victim@example.com", verified: false },
|
|
157
|
+
});
|
|
158
|
+
expect(patch.contactProperties.discordEmail).toBeUndefined();
|
|
159
|
+
expect(patch.contactProperties.isDiscordLinked).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("OMITS an absent email", () => {
|
|
163
|
+
const patch = memberLinkToContactPatch({
|
|
164
|
+
user: { id: "u3", email: null, verified: true },
|
|
165
|
+
});
|
|
166
|
+
expect(patch.contactProperties.discordEmail).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("discordDestination.transform", () => {
|
|
171
|
+
const logger = {} as never;
|
|
172
|
+
|
|
173
|
+
it("prefers the incoming-webhook wire (no bot token), accepts 204", () => {
|
|
174
|
+
const result = discordDestination.transform(
|
|
175
|
+
{ id: "e1", type: "email.sent", timestamp: "t", data: { to: "a@b.c" } },
|
|
176
|
+
{
|
|
177
|
+
logger,
|
|
178
|
+
endpoint: makeEndpoint({
|
|
179
|
+
config: { webhookUrl: "https://discord.com/api/webhooks/1/abc" },
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
expect(result?.url).toBe("https://discord.com/api/webhooks/1/abc");
|
|
184
|
+
expect(result?.isSuccess?.(204, "")).toBe(true);
|
|
185
|
+
expect(JSON.parse(result?.body ?? "{}").content).toContain("email.sent");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("falls back to bot-REST with the channel id + token", () => {
|
|
189
|
+
const result = discordDestination.transform(
|
|
190
|
+
{ id: "e2", type: "email.opened", timestamp: "t", data: {} },
|
|
191
|
+
{
|
|
192
|
+
logger,
|
|
193
|
+
endpoint: makeEndpoint({
|
|
194
|
+
secret: "bot-token",
|
|
195
|
+
config: { channelId: "chan1" },
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
expect(result?.url).toContain("/channels/chan1/messages");
|
|
200
|
+
expect(result?.headers.Authorization).toBe("Bot bot-token");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("throws (config error → DLQ) when neither wire is configured", () => {
|
|
204
|
+
expect(() =>
|
|
205
|
+
discordDestination.transform(
|
|
206
|
+
{ id: "e3", type: "email.sent", timestamp: "t", data: {} },
|
|
207
|
+
{
|
|
208
|
+
logger,
|
|
209
|
+
endpoint: makeEndpoint({ config: {} }),
|
|
210
|
+
},
|
|
211
|
+
),
|
|
212
|
+
).toThrow(/needs config.webhookUrl/);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DISCORD_API_BASE } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edit the ORIGINAL response of a deferred interaction.
|
|
5
|
+
*
|
|
6
|
+
* The deferred modal-submit steps (`/link` email → mint+send; the code modal →
|
|
7
|
+
* redeem+resolve) return a type-5 ack inside Discord's hard 3-second window and
|
|
8
|
+
* then do their real work — the anti-email-bomb throttle, the code mint, the
|
|
9
|
+
* provider HTTP send, the redeem + DB attach — out of band. When that work
|
|
10
|
+
* resolves, this PATCHes the deferred "thinking…" message into the final
|
|
11
|
+
* ephemeral reply via Discord's interaction-webhook endpoint
|
|
12
|
+
* (`PATCH /webhooks/{applicationId}/{token}/messages/@original`).
|
|
13
|
+
*
|
|
14
|
+
* The PATCH authenticates with the APPLICATION ID + the per-interaction TOKEN
|
|
15
|
+
* ONLY — NO bot token, no Authorization header (this is the interaction-webhook
|
|
16
|
+
* endpoint, not the bot REST API). The body is a full message body so callers
|
|
17
|
+
* can edit `content` (plain replies), `components` (the Enter-code button), or a
|
|
18
|
+
* Components-V2 success card (`flags: 32832`).
|
|
19
|
+
*
|
|
20
|
+
* The interaction token is short-lived (Discord allows ~15 min of follow-ups);
|
|
21
|
+
* a deferred reply edited well within that window always lands. The call is
|
|
22
|
+
* best-effort: a failed PATCH leaves the user on the "thinking…" state, so the
|
|
23
|
+
* caller logs (never throws) — the code is already minted+emailed regardless.
|
|
24
|
+
*
|
|
25
|
+
* SECRET HYGIENE: the `token` authenticates the follow-up; it is NEVER logged.
|
|
26
|
+
* On a non-2xx we surface ONLY the HTTP status, never the response body (which
|
|
27
|
+
* could echo request material).
|
|
28
|
+
*/
|
|
29
|
+
export async function editInteractionResponse(args: {
|
|
30
|
+
applicationId: string;
|
|
31
|
+
/** The interaction token from the original payload — authenticates the edit. */
|
|
32
|
+
token: string;
|
|
33
|
+
/** Full message body — `{ content?, components?, flags? }`. */
|
|
34
|
+
body: Record<string, unknown>;
|
|
35
|
+
}): Promise<void> {
|
|
36
|
+
const url =
|
|
37
|
+
`${DISCORD_API_BASE}/webhooks/${args.applicationId}/${args.token}` +
|
|
38
|
+
"/messages/@original";
|
|
39
|
+
// RACE: the deferred (type-5) ack is delivered by the engine route AFTER this
|
|
40
|
+
// handler returns, so a fast follow-up (work resolved in <1s) can reach Discord
|
|
41
|
+
// BEFORE it has registered the deferral — the @original message then 404s ("not
|
|
42
|
+
// ready yet"). Retry ONLY the 404 with short backoff; it lands as soon as the
|
|
43
|
+
// deferral registers (well within the 15-min token window). Any other status
|
|
44
|
+
// fails fast (a real error, not a timing artifact).
|
|
45
|
+
const MAX_ATTEMPTS = 5;
|
|
46
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: "PATCH",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify(args.body),
|
|
51
|
+
});
|
|
52
|
+
if (res.ok) return;
|
|
53
|
+
if (res.status === 404 && attempt < MAX_ATTEMPTS) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * attempt));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`discord interaction follow-up failed (${res.status})`);
|
|
58
|
+
}
|
|
59
|
+
}
|