@helyx/bot 1.3.1 → 1.4.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/CHANGELOG.md +77 -28
- package/README.md +65 -11
- package/dist/cloud-connector.d.ts +11 -4
- package/dist/cloud-connector.js +52 -15
- package/dist/cloud-control-dispatcher.d.ts +8 -3
- package/dist/cloud-control-dispatcher.js +62 -4
- package/dist/control-errors.d.ts +4 -0
- package/dist/control-errors.js +8 -0
- package/dist/control-server-schemas.d.ts +84 -0
- package/dist/control-server-schemas.js +101 -0
- package/dist/control-server.d.ts +21 -7
- package/dist/control-server.js +88 -132
- package/dist/core-retention.d.ts +26 -0
- package/dist/core-retention.js +49 -0
- package/dist/creator-guild-join-service.d.ts +25 -0
- package/dist/creator-guild-join-service.js +218 -0
- package/dist/creator-invite-service.d.ts +37 -0
- package/dist/creator-invite-service.js +686 -0
- package/dist/creator-onboarding-control.d.ts +12 -0
- package/dist/creator-onboarding-control.js +65 -0
- package/dist/creator-onboarding-platform.d.ts +20 -0
- package/dist/creator-onboarding-platform.js +58 -0
- package/dist/dashboard-action-control.d.ts +2 -2
- package/dist/dashboard-action-control.js +2 -3
- package/dist/database-bootstrap.d.ts +36 -0
- package/dist/database-bootstrap.js +101 -0
- package/dist/developer-operations-module.d.ts +1 -0
- package/dist/developer-operations-module.js +34 -16
- package/dist/discord-owned-message-service.d.ts +1 -0
- package/dist/discord-owned-message-service.js +3 -0
- package/dist/guild-command-read-service.d.ts +8 -3
- package/dist/guild-command-read-service.js +14 -7
- package/dist/guild-command-release.d.ts +15 -8
- package/dist/guild-command-release.js +10 -3
- package/dist/guild-command-worker.d.ts +17 -4
- package/dist/guild-command-worker.js +17 -6
- package/dist/guilds.d.ts +24 -3
- package/dist/guilds.js +185 -64
- package/dist/hosted-server-moderation.d.ts +2 -2
- package/dist/hosted-server-moderation.js +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +200 -82
- package/dist/interactions.js +16 -3
- package/dist/module-configuration.d.ts +1 -1
- package/dist/module-configuration.js +5 -2
- package/dist/module-logging.d.ts +8 -3
- package/dist/module-logging.js +13 -7
- package/dist/modules.d.ts +1 -0
- package/dist/modules.js +8 -1
- package/dist/mysql-runtime-persistence.d.ts +46 -0
- package/dist/mysql-runtime-persistence.js +91 -0
- package/dist/oauth-module.js +2 -2
- package/dist/oauth-service.d.ts +7 -0
- package/dist/oauth-service.js +3 -0
- package/dist/scheduled-task-runner.d.ts +9 -85
- package/dist/scheduled-task-runner.js +1 -1
- package/dist/shard-contracts.d.ts +135 -0
- package/dist/shard-contracts.js +13 -1
- package/dist/shard-router.d.ts +2 -1
- package/dist/shard-router.js +7 -0
- package/dist/shard-supervisor.js +43 -9
- package/dist/shard-worker.d.ts +5 -2
- package/dist/shard-worker.js +41 -5
- package/dist/supervisor-cloud-directory.d.ts +9 -3
- package/dist/supervisor-cloud-directory.js +8 -12
- package/dist/trivia-data-provider.d.ts +11 -0
- package/dist/trivia-data-provider.js +46 -26
- package/dist/verification-service.d.ts +2 -2
- package/dist/verification-service.js +6 -3
- package/documentation/README.md +5 -3
- package/documentation/examples/.env.example +15 -11
- package/documentation/guides/getting-started.md +29 -12
- package/documentation/guides/privacy-and-support.md +6 -5
- package/documentation/guides/self-hosting.md +16 -11
- package/documentation/modules/auto-moderation.md +7 -5
- package/documentation/modules/building-a-module.md +48 -2
- package/documentation/modules/moderation.md +5 -5
- package/documentation/modules/publishing-a-module.md +6 -4
- package/documentation/modules/testing-a-module.md +39 -4
- package/documentation/reference/environment.md +43 -9
- package/documentation/runbooks/backup-and-recovery.md +26 -7
- package/documentation/runbooks/installation-and-upgrades.md +97 -27
- package/package.json +20 -16
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { isCreatorOverviewRouteCode } from "@helyx/control-protocol/creator-overview-routing";
|
|
2
|
+
import { CREATOR_INVITE_REGENERATION_COOLDOWN_MS, CreatorInviteOperationError, HELYX_SERVICE_NAMES, isPublicCreatorRoleEligible, } from "@helyx/sdk";
|
|
3
|
+
import { ChannelType, DiscordAPIError, PermissionFlagsBits, REST, RESTJSONErrorCodes, Routes, } from "discord.js";
|
|
4
|
+
import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
5
|
+
import { normalizeDiscordPermission } from "./interactions.js";
|
|
6
|
+
import { canAccessModule } from "./premium-access.js";
|
|
7
|
+
import { discordRestOptions } from "./discord-rest.js";
|
|
8
|
+
const MODULE_ID = "helyx.creator-connections";
|
|
9
|
+
const snowflake = /^\d{17,20}$/u;
|
|
10
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
11
|
+
const inviteCode = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
12
|
+
/** Hosted, guild-owning core adapter. Modules never receive its encryption key/store. */
|
|
13
|
+
export class DiscordCreatorInviteService {
|
|
14
|
+
client;
|
|
15
|
+
repository;
|
|
16
|
+
services;
|
|
17
|
+
module;
|
|
18
|
+
#key;
|
|
19
|
+
#rest = null;
|
|
20
|
+
#cleanupCursor;
|
|
21
|
+
constructor(client, repository, services, module, encodedEncryptionKey) {
|
|
22
|
+
this.client = client;
|
|
23
|
+
this.repository = repository;
|
|
24
|
+
this.services = services;
|
|
25
|
+
this.module = module;
|
|
26
|
+
const rootKey = Buffer.from(encodedEncryptionKey, "base64");
|
|
27
|
+
if (rootKey.length !== 32)
|
|
28
|
+
throw new Error("Creator invites require a 32-byte data encryption key");
|
|
29
|
+
this.#key = createHmac("sha256", rootKey)
|
|
30
|
+
.update("helyx.creator-owned-invites.v1")
|
|
31
|
+
.digest();
|
|
32
|
+
}
|
|
33
|
+
async selectableRoles(input) {
|
|
34
|
+
validateId(input.guildId);
|
|
35
|
+
validateId(input.actorUserId);
|
|
36
|
+
const guild = this.#ownedGuild(input.guildId);
|
|
37
|
+
await this.#authorise(guild, input.actorUserId);
|
|
38
|
+
const permissions = this.services.get(HELYX_SERVICE_NAMES.permissions);
|
|
39
|
+
if (!permissions.getGuildAccessPolicy)
|
|
40
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
41
|
+
const policy = await permissions.getGuildAccessPolicy(guild.id);
|
|
42
|
+
const roles = await guild.roles.fetch();
|
|
43
|
+
const me = await guild.members.fetchMe({ force: true });
|
|
44
|
+
const actor = await guild.members.fetch({
|
|
45
|
+
user: input.actorUserId,
|
|
46
|
+
force: true,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
roleIds: roles
|
|
50
|
+
.filter((role) => isPublicCreatorRoleEligible({
|
|
51
|
+
guildId: guild.id,
|
|
52
|
+
roleId: role.id,
|
|
53
|
+
name: role.name,
|
|
54
|
+
exists: true,
|
|
55
|
+
guildMatches: true,
|
|
56
|
+
managed: role.managed,
|
|
57
|
+
everyone: role.id === guild.id,
|
|
58
|
+
assignable: role.editable,
|
|
59
|
+
actorAssignable: actor.id === guild.ownerId ||
|
|
60
|
+
actor.roles.highest.comparePositionTo(role) > 0,
|
|
61
|
+
hierarchy: "assignable",
|
|
62
|
+
permissionNames: role.permissions
|
|
63
|
+
.toArray()
|
|
64
|
+
.map(normalizeDiscordPermission),
|
|
65
|
+
dangerousPermissionNames: [],
|
|
66
|
+
}, policy) && (role.permissions.bitfield & ~me.permissions.bitfield) === 0n)
|
|
67
|
+
.map((role) => role.id),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async create(input) {
|
|
71
|
+
validateId(input.guildId);
|
|
72
|
+
validateId(input.actorUserId);
|
|
73
|
+
validateId(input.channelId);
|
|
74
|
+
validateReference(input.operationId);
|
|
75
|
+
if (input.replacesReference)
|
|
76
|
+
validateReference(input.replacesReference);
|
|
77
|
+
if ((input.purpose === "welcome" && !input.roleId) ||
|
|
78
|
+
(input.purpose === "overview" && input.roleId !== null) ||
|
|
79
|
+
!["welcome", "overview"].includes(input.purpose))
|
|
80
|
+
throw new CreatorInviteOperationError("invalid_role");
|
|
81
|
+
const guild = this.#ownedGuild(input.guildId);
|
|
82
|
+
await this.#authorise(guild, input.actorUserId);
|
|
83
|
+
await this.#validateDestination(guild, input);
|
|
84
|
+
return this.repository.runGuildExclusive(input.guildId, async (store) => {
|
|
85
|
+
let record = await store.findByOperation(input.operationId);
|
|
86
|
+
if (record) {
|
|
87
|
+
if (record.channelId !== input.channelId ||
|
|
88
|
+
record.roleId !== input.roleId ||
|
|
89
|
+
record.purpose !== input.purpose ||
|
|
90
|
+
record.replacesReference !== (input.replacesReference ?? null))
|
|
91
|
+
throw new CreatorInviteOperationError("operation_conflict");
|
|
92
|
+
if (record.state === "rejected") {
|
|
93
|
+
const beforeFingerprints = (await this.#list(record.channelId)).map((invite) => this.#fingerprint(invite.code));
|
|
94
|
+
const createdAt = new Date();
|
|
95
|
+
await store.retryRejected(record.reference, beforeFingerprints, createdAt);
|
|
96
|
+
record = await this.#createIntent(store, {
|
|
97
|
+
...record,
|
|
98
|
+
state: "creating",
|
|
99
|
+
beforeFingerprints,
|
|
100
|
+
createdAt,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
else if (!record.encryptedCode && record.state !== "revoked")
|
|
104
|
+
record = await this.#recover(store, record);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const previous = input.replacesReference
|
|
108
|
+
? await store.find(input.replacesReference)
|
|
109
|
+
: null;
|
|
110
|
+
if (input.replacesReference &&
|
|
111
|
+
(!previous || previous.purpose !== input.purpose))
|
|
112
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
113
|
+
if (previous && (await store.hasSuccessor(previous.reference)))
|
|
114
|
+
throw new CreatorInviteOperationError("operation_conflict");
|
|
115
|
+
const overviewCooldown = input.purpose === "overview" ? await store.overviewCooldown() : null;
|
|
116
|
+
if (overviewCooldown && overviewCooldown.getTime() > Date.now())
|
|
117
|
+
throw new CreatorInviteOperationError("cooldown", overviewCooldown.toISOString());
|
|
118
|
+
if (previous?.regenerateAfter &&
|
|
119
|
+
previous.regenerateAfter.getTime() > Date.now())
|
|
120
|
+
throw new CreatorInviteOperationError("cooldown", previous.regenerateAfter.toISOString());
|
|
121
|
+
if (!previous &&
|
|
122
|
+
input.purpose === "overview" &&
|
|
123
|
+
(await store.hasUnfinishedOverview()))
|
|
124
|
+
throw new CreatorInviteOperationError("operation_conflict");
|
|
125
|
+
const before = await this.#list(input.channelId);
|
|
126
|
+
const now = new Date();
|
|
127
|
+
record = {
|
|
128
|
+
reference: randomUUID(),
|
|
129
|
+
operationId: input.operationId,
|
|
130
|
+
guildId: input.guildId,
|
|
131
|
+
channelId: input.channelId,
|
|
132
|
+
roleId: input.roleId,
|
|
133
|
+
purpose: input.purpose,
|
|
134
|
+
replacesReference: input.replacesReference ?? null,
|
|
135
|
+
state: "creating",
|
|
136
|
+
beforeFingerprints: before.map((invite) => this.#fingerprint(invite.code)),
|
|
137
|
+
encryptedCode: null,
|
|
138
|
+
regenerateAfter: previous
|
|
139
|
+
? new Date(now.getTime() + CREATOR_INVITE_REGENERATION_COOLDOWN_MS)
|
|
140
|
+
: null,
|
|
141
|
+
createdAt: now,
|
|
142
|
+
};
|
|
143
|
+
// Commit intent before Discord. Never retry a POST whose result is unknown.
|
|
144
|
+
await store.insert(record);
|
|
145
|
+
record = await this.#createIntent(store, record);
|
|
146
|
+
}
|
|
147
|
+
if (record.state !== "active")
|
|
148
|
+
throw new CreatorInviteOperationError("creation_uncertain");
|
|
149
|
+
await this.#assertOverviewRoutable(store, record);
|
|
150
|
+
if (record.replacesReference) {
|
|
151
|
+
const previous = await store.find(record.replacesReference);
|
|
152
|
+
if (!previous ||
|
|
153
|
+
(await this.#revoke(store, previous)).state !== "revoked")
|
|
154
|
+
throw new CreatorInviteOperationError("replacement_pending");
|
|
155
|
+
}
|
|
156
|
+
return publicInvite(record);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async inspect(input) {
|
|
160
|
+
validateId(input.guildId);
|
|
161
|
+
validateReference(input.reference);
|
|
162
|
+
this.#ownedGuild(input.guildId);
|
|
163
|
+
return this.repository.runGuildExclusive(input.guildId, async (store) => {
|
|
164
|
+
let record = await store.find(input.reference);
|
|
165
|
+
if (!record)
|
|
166
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
167
|
+
if (!record.encryptedCode &&
|
|
168
|
+
!["revoked", "rejected"].includes(record.state))
|
|
169
|
+
record = await this.#recover(store, record);
|
|
170
|
+
if (record.encryptedCode && record.state === "active") {
|
|
171
|
+
const code = this.#decrypt(record);
|
|
172
|
+
const invite = (await this.#list(record.channelId)).find((item) => item.code === code);
|
|
173
|
+
const state = !invite
|
|
174
|
+
? "revoked"
|
|
175
|
+
: this.#matches(invite, record)
|
|
176
|
+
? "active"
|
|
177
|
+
: "unknown";
|
|
178
|
+
if (state !== record.state) {
|
|
179
|
+
await store.update(record.reference, state);
|
|
180
|
+
record = { ...record, state };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return publicInvite(record);
|
|
184
|
+
}, { waitMs: 5_000 });
|
|
185
|
+
}
|
|
186
|
+
async shareUrl(input) {
|
|
187
|
+
validateId(input.guildId);
|
|
188
|
+
validateId(input.actorUserId);
|
|
189
|
+
validateReference(input.reference);
|
|
190
|
+
const guild = this.#ownedGuild(input.guildId);
|
|
191
|
+
await this.#authorise(guild, input.actorUserId);
|
|
192
|
+
return this.repository.runGuildExclusive(input.guildId, async (store) => {
|
|
193
|
+
const record = await store.find(input.reference);
|
|
194
|
+
if (!record || record.state !== "active" || !record.encryptedCode)
|
|
195
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
196
|
+
// An old public welcome link must not keep granting a role which has
|
|
197
|
+
// since become privileged or moved outside the current hierarchy.
|
|
198
|
+
await this.#validateDestination(guild, {
|
|
199
|
+
guildId: record.guildId,
|
|
200
|
+
channelId: record.channelId,
|
|
201
|
+
roleId: record.roleId,
|
|
202
|
+
purpose: record.purpose,
|
|
203
|
+
operationId: record.operationId,
|
|
204
|
+
actorUserId: input.actorUserId,
|
|
205
|
+
});
|
|
206
|
+
await this.#assertOverviewRoutable(store, record);
|
|
207
|
+
const code = this.#decrypt(record);
|
|
208
|
+
const current = (await this.#list(record.channelId)).find((invite) => invite.code === code);
|
|
209
|
+
if (!current || !this.#matches(current, record))
|
|
210
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
211
|
+
if (record.replacesReference &&
|
|
212
|
+
(await store.find(record.replacesReference))?.state !== "revoked")
|
|
213
|
+
throw new CreatorInviteOperationError("replacement_pending");
|
|
214
|
+
return { url: `https://discord.com/invite/${code}` };
|
|
215
|
+
}, { waitMs: 5_000 });
|
|
216
|
+
}
|
|
217
|
+
async publicOverview(input) {
|
|
218
|
+
validateId(input.guildId);
|
|
219
|
+
validateReference(input.reference);
|
|
220
|
+
const guild = this.#ownedGuild(input.guildId);
|
|
221
|
+
if (!(await this.#enabled(guild.id)))
|
|
222
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
223
|
+
const record = await this.repository.findOwnedOverview(input);
|
|
224
|
+
if (!record ||
|
|
225
|
+
record.purpose !== "overview" ||
|
|
226
|
+
record.roleId !== null ||
|
|
227
|
+
record.state !== "active" ||
|
|
228
|
+
!record.encryptedCode)
|
|
229
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
230
|
+
if (record.replacesReference &&
|
|
231
|
+
(await this.repository.findOwnedOverview({
|
|
232
|
+
guildId: guild.id,
|
|
233
|
+
reference: record.replacesReference,
|
|
234
|
+
}))?.state !== "revoked")
|
|
235
|
+
throw new CreatorInviteOperationError("replacement_pending");
|
|
236
|
+
const code = this.#decrypt(record);
|
|
237
|
+
// A GET never mutates ownership. Background cleanup handles invalid codes;
|
|
238
|
+
// create/revoke/join operations retain their exclusive fences.
|
|
239
|
+
if (!isCreatorOverviewRouteCode(code))
|
|
240
|
+
throw new CreatorInviteOperationError("operation_conflict");
|
|
241
|
+
const actual = (await this.#list(record.channelId)).find((invite) => invite.code === code);
|
|
242
|
+
if (!actual || !this.#matches(actual, record))
|
|
243
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
244
|
+
if (!(await this.repository.isActiveOverview(input)))
|
|
245
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
246
|
+
return {
|
|
247
|
+
guildName: guild.name,
|
|
248
|
+
guildIconUrl: guild.iconURL({ size: 256 }),
|
|
249
|
+
discordInviteUrl: `https://discord.com/invite/${code}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
async revoke(input) {
|
|
253
|
+
validateId(input.guildId);
|
|
254
|
+
validateReference(input.reference);
|
|
255
|
+
validateReference(input.operationId);
|
|
256
|
+
this.#ownedGuild(input.guildId);
|
|
257
|
+
return this.repository.runGuildExclusive(input.guildId, async (store) => {
|
|
258
|
+
let record = await store.find(input.reference);
|
|
259
|
+
if (!record)
|
|
260
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
261
|
+
if (!record.encryptedCode &&
|
|
262
|
+
!["revoked", "rejected"].includes(record.state))
|
|
263
|
+
record = await this.#recover(store, record);
|
|
264
|
+
return this.#revoke(store, record);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/** Cancels a durable intent without issuing a create POST, including rejected intents. */
|
|
268
|
+
async cancelOperation(input) {
|
|
269
|
+
validateId(input.guildId);
|
|
270
|
+
validateReference(input.operationId);
|
|
271
|
+
this.#ownedGuild(input.guildId);
|
|
272
|
+
return this.repository.runGuildExclusive(input.guildId, async (store) => {
|
|
273
|
+
let record = await store.findByOperation(input.operationId);
|
|
274
|
+
// Intent persistence always precedes Discord creation. No intent means
|
|
275
|
+
// this operation cannot have issued a Discord POST.
|
|
276
|
+
if (!record)
|
|
277
|
+
return { state: "revoked" };
|
|
278
|
+
if (!record.encryptedCode &&
|
|
279
|
+
!["revoked", "rejected"].includes(record.state)) {
|
|
280
|
+
try {
|
|
281
|
+
record = await this.#recover(store, record);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (unknownChannel(error)) {
|
|
285
|
+
await store.update(record.reference, "revoked");
|
|
286
|
+
return { state: "revoked" };
|
|
287
|
+
}
|
|
288
|
+
if (error instanceof CreatorInviteOperationError &&
|
|
289
|
+
error.code === "operation_conflict") {
|
|
290
|
+
// Recovery may have identified, encrypted and already revoked an
|
|
291
|
+
// unsuitable public route code. Return that durable cancellation.
|
|
292
|
+
const reconciled = await store.find(record.reference);
|
|
293
|
+
if (reconciled?.state === "revoked")
|
|
294
|
+
return { state: "revoked" };
|
|
295
|
+
if (reconciled?.state === "revocation_pending")
|
|
296
|
+
return { state: "revocation_pending" };
|
|
297
|
+
}
|
|
298
|
+
return { state: "unknown" };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return this.#revoke(store, record);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/** Runs outside module enablement; only proven access/safety loss initiates revocation. */
|
|
305
|
+
async cleanupUnavailableInvites() {
|
|
306
|
+
const candidates = await this.repository.listCleanupCandidates(50, this.#cleanupCursor);
|
|
307
|
+
const enabledGuilds = new Map();
|
|
308
|
+
let delayed = false;
|
|
309
|
+
for (const candidate of candidates) {
|
|
310
|
+
this.#cleanupCursor = candidate.reference;
|
|
311
|
+
if (!this.client.isReady() ||
|
|
312
|
+
!this.client.guilds.cache.get(candidate.guildId)?.available)
|
|
313
|
+
continue;
|
|
314
|
+
try {
|
|
315
|
+
let enabled = enabledGuilds.get(candidate.guildId);
|
|
316
|
+
if (enabled === undefined) {
|
|
317
|
+
enabled = await this.#enabled(candidate.guildId);
|
|
318
|
+
enabledGuilds.set(candidate.guildId, enabled);
|
|
319
|
+
}
|
|
320
|
+
await this.repository.runGuildExclusive(candidate.guildId, async (store) => {
|
|
321
|
+
let record = await store.find(candidate.reference);
|
|
322
|
+
if (!record || record.state === "revoked")
|
|
323
|
+
return;
|
|
324
|
+
const guild = this.#ownedGuild(candidate.guildId);
|
|
325
|
+
// Retry already-started revocations even if the module was reenabled.
|
|
326
|
+
if (enabled &&
|
|
327
|
+
record.state !== "revocation_pending" &&
|
|
328
|
+
(await this.#welcomeRoleSafe(guild, record)))
|
|
329
|
+
return;
|
|
330
|
+
if (!record.encryptedCode && record.state !== "rejected") {
|
|
331
|
+
try {
|
|
332
|
+
record = await this.#recover(store, record);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (unknownChannel(error)) {
|
|
336
|
+
await store.update(record.reference, "revoked");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if ((await this.#revoke(store, record)).state !== "revoked")
|
|
343
|
+
delayed = true;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// One inaccessible guild must not starve cleanup in every later guild.
|
|
348
|
+
delayed = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (candidates.length < 50)
|
|
352
|
+
this.#cleanupCursor = undefined;
|
|
353
|
+
if (delayed)
|
|
354
|
+
throw new Error("Creator invite cleanup requires reconciliation");
|
|
355
|
+
}
|
|
356
|
+
async #welcomeRoleSafe(guild, record) {
|
|
357
|
+
if (record.purpose === "overview")
|
|
358
|
+
return (!record.encryptedCode ||
|
|
359
|
+
isCreatorOverviewRouteCode(this.#decrypt(record)));
|
|
360
|
+
if (!record.roleId)
|
|
361
|
+
return false;
|
|
362
|
+
const permissions = this.services.get(HELYX_SERVICE_NAMES.permissions);
|
|
363
|
+
if (!permissions.getGuildAccessPolicy)
|
|
364
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
365
|
+
const [policy, roles, me] = await Promise.all([
|
|
366
|
+
permissions.getGuildAccessPolicy(guild.id),
|
|
367
|
+
guild.roles.fetch(),
|
|
368
|
+
guild.members.fetchMe({ force: true }),
|
|
369
|
+
]);
|
|
370
|
+
const role = roles.get(record.roleId);
|
|
371
|
+
return Boolean(role &&
|
|
372
|
+
isPublicCreatorRoleEligible({
|
|
373
|
+
guildId: guild.id,
|
|
374
|
+
roleId: role.id,
|
|
375
|
+
name: role.name,
|
|
376
|
+
exists: true,
|
|
377
|
+
guildMatches: true,
|
|
378
|
+
managed: role.managed,
|
|
379
|
+
everyone: role.id === guild.id,
|
|
380
|
+
assignable: role.editable,
|
|
381
|
+
// No manager is present during background cleanup. Its safety decision
|
|
382
|
+
// concerns bot hierarchy, configured staff roles and current permissions.
|
|
383
|
+
actorAssignable: true,
|
|
384
|
+
hierarchy: "assignable",
|
|
385
|
+
permissionNames: role.permissions
|
|
386
|
+
.toArray()
|
|
387
|
+
.map(normalizeDiscordPermission),
|
|
388
|
+
dangerousPermissionNames: [],
|
|
389
|
+
}, policy) &&
|
|
390
|
+
(role.permissions.bitfield & ~me.permissions.bitfield) === 0n);
|
|
391
|
+
}
|
|
392
|
+
async #assertOverviewRoutable(store, record) {
|
|
393
|
+
if (record.purpose !== "overview" ||
|
|
394
|
+
isCreatorOverviewRouteCode(this.#decrypt(record)))
|
|
395
|
+
return;
|
|
396
|
+
// The raw code has already been encrypted and durably stored. Revocation
|
|
397
|
+
// can therefore retry after a lost delete response without another POST.
|
|
398
|
+
// A new manager operation may replace it only after cancellation is proven.
|
|
399
|
+
await this.#revoke(store, record);
|
|
400
|
+
throw new CreatorInviteOperationError("operation_conflict");
|
|
401
|
+
}
|
|
402
|
+
async #revoke(store, record) {
|
|
403
|
+
if (record.state === "revoked")
|
|
404
|
+
return { state: "revoked" };
|
|
405
|
+
if (record.state === "rejected") {
|
|
406
|
+
await store.update(record.reference, "revoked");
|
|
407
|
+
return { state: "revoked" };
|
|
408
|
+
}
|
|
409
|
+
if (!record.encryptedCode)
|
|
410
|
+
return { state: "unknown" };
|
|
411
|
+
await store.update(record.reference, "revocation_pending");
|
|
412
|
+
try {
|
|
413
|
+
const code = this.#decrypt(record);
|
|
414
|
+
// An already-deleted destination means its channel-backed invite cannot remain usable.
|
|
415
|
+
try {
|
|
416
|
+
await this.#discord().delete(Routes.invite(code), {
|
|
417
|
+
reason: "Helyx Creator Connections owned invite revoked",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (!unknownInvite(error))
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
const remaining = await this.#list(record.channelId).catch((error) => {
|
|
425
|
+
if (unknownChannel(error))
|
|
426
|
+
return [];
|
|
427
|
+
throw error;
|
|
428
|
+
});
|
|
429
|
+
if (remaining.some((invite) => invite.code === code))
|
|
430
|
+
return { state: "revocation_pending" };
|
|
431
|
+
await store.update(record.reference, "revoked");
|
|
432
|
+
return { state: "revoked" };
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return { state: "revocation_pending" };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async #createIntent(store, record) {
|
|
439
|
+
const body = {
|
|
440
|
+
max_age: 0,
|
|
441
|
+
max_uses: 0,
|
|
442
|
+
temporary: false,
|
|
443
|
+
unique: true,
|
|
444
|
+
...(record.roleId ? { role_ids: [record.roleId] } : {}),
|
|
445
|
+
};
|
|
446
|
+
let created;
|
|
447
|
+
try {
|
|
448
|
+
created = (await this.#discord().post(Routes.channelInvites(record.channelId), {
|
|
449
|
+
body,
|
|
450
|
+
reason: `Helyx Creator Connections ${record.purpose}; operation ${record.operationId}`,
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
// A returned Discord API rejection is different from a lost response or
|
|
455
|
+
// transport failure. Only the former permits a new POST for this intent.
|
|
456
|
+
const rejected = error instanceof DiscordAPIError &&
|
|
457
|
+
error.status >= 400 &&
|
|
458
|
+
error.status < 500 &&
|
|
459
|
+
error.status !== 429;
|
|
460
|
+
await store.update(record.reference, rejected ? "rejected" : "unknown");
|
|
461
|
+
throw new CreatorInviteOperationError(rejected ? "creation_rejected" : "creation_uncertain");
|
|
462
|
+
}
|
|
463
|
+
if (!this.#matches(created, record)) {
|
|
464
|
+
await store.update(record.reference, "unknown");
|
|
465
|
+
throw new CreatorInviteOperationError("creation_uncertain");
|
|
466
|
+
}
|
|
467
|
+
const encryptedCode = this.#encrypt(record, created.code);
|
|
468
|
+
// If this write fails, the durable intent remains recoverable; never POST again.
|
|
469
|
+
await store.update(record.reference, "active", encryptedCode, record.purpose === "overview"
|
|
470
|
+
? createHash("sha256").update(created.code).digest("hex")
|
|
471
|
+
: undefined);
|
|
472
|
+
const ownedRecord = { ...record, state: "active", encryptedCode };
|
|
473
|
+
await this.#assertOverviewRoutable(store, ownedRecord);
|
|
474
|
+
return ownedRecord;
|
|
475
|
+
}
|
|
476
|
+
async #recover(store, record) {
|
|
477
|
+
const candidates = (await this.#list(record.channelId)).filter((invite) => !record.beforeFingerprints.includes(this.#fingerprint(invite.code)) &&
|
|
478
|
+
this.#matches(invite, record) &&
|
|
479
|
+
invite.created_at &&
|
|
480
|
+
new Date(invite.created_at).getTime() >=
|
|
481
|
+
record.createdAt.getTime() - 1000);
|
|
482
|
+
// Candidate shape alone is not ownership proof: another bot operation may have
|
|
483
|
+
// created it. Recover only when Discord's audit reason names this exact intent.
|
|
484
|
+
if (candidates.length !== 1)
|
|
485
|
+
throw new CreatorInviteOperationError("creation_uncertain");
|
|
486
|
+
const audit = (await this.#discord()
|
|
487
|
+
.get(Routes.guildAuditLog(record.guildId), {
|
|
488
|
+
query: new URLSearchParams({
|
|
489
|
+
action_type: "40",
|
|
490
|
+
user_id: this.client.user.id,
|
|
491
|
+
limit: "100",
|
|
492
|
+
}),
|
|
493
|
+
})
|
|
494
|
+
.catch(() => null));
|
|
495
|
+
const expectedReason = `Helyx Creator Connections ${record.purpose}; operation ${record.operationId}`;
|
|
496
|
+
const owned = audit?.audit_log_entries.some((entry) => entry.user_id === this.client.user.id &&
|
|
497
|
+
entry.reason === expectedReason &&
|
|
498
|
+
entry.changes?.some((change) => change.key === "code" &&
|
|
499
|
+
"new_value" in change &&
|
|
500
|
+
change.new_value === candidates[0].code));
|
|
501
|
+
if (!owned)
|
|
502
|
+
throw new CreatorInviteOperationError("creation_uncertain");
|
|
503
|
+
const encryptedCode = this.#encrypt(record, candidates[0].code);
|
|
504
|
+
await store.update(record.reference, "active", encryptedCode, record.purpose === "overview"
|
|
505
|
+
? createHash("sha256").update(candidates[0].code).digest("hex")
|
|
506
|
+
: undefined);
|
|
507
|
+
const ownedRecord = { ...record, state: "active", encryptedCode };
|
|
508
|
+
await this.#assertOverviewRoutable(store, ownedRecord);
|
|
509
|
+
return ownedRecord;
|
|
510
|
+
}
|
|
511
|
+
#matches(invite, record) {
|
|
512
|
+
const roles = invite.roles?.map((role) => role.id) ?? [];
|
|
513
|
+
return (inviteCode.test(invite.code) &&
|
|
514
|
+
invite.guild?.id === record.guildId &&
|
|
515
|
+
invite.channel?.id === record.channelId &&
|
|
516
|
+
invite.inviter?.id === this.client.user?.id &&
|
|
517
|
+
invite.max_age === 0 &&
|
|
518
|
+
invite.max_uses === 0 &&
|
|
519
|
+
invite.temporary === false &&
|
|
520
|
+
(record.roleId
|
|
521
|
+
? roles.length === 1 && roles[0] === record.roleId
|
|
522
|
+
: roles.length === 0));
|
|
523
|
+
}
|
|
524
|
+
async #validateDestination(guild, input) {
|
|
525
|
+
await guild.roles.fetch();
|
|
526
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
527
|
+
const me = await guild.members.fetchMe({ force: true });
|
|
528
|
+
const actor = await guild.members.fetch({
|
|
529
|
+
user: input.actorUserId,
|
|
530
|
+
force: true,
|
|
531
|
+
});
|
|
532
|
+
if (!channel ||
|
|
533
|
+
![ChannelType.GuildText, ChannelType.GuildAnnouncement].includes(channel.type) ||
|
|
534
|
+
!channel.viewable ||
|
|
535
|
+
!channel
|
|
536
|
+
.permissionsFor(me)
|
|
537
|
+
?.has([
|
|
538
|
+
PermissionFlagsBits.ViewChannel,
|
|
539
|
+
PermissionFlagsBits.CreateInstantInvite,
|
|
540
|
+
PermissionFlagsBits.ManageChannels,
|
|
541
|
+
]) ||
|
|
542
|
+
!channel.permissionsFor(actor)?.has(PermissionFlagsBits.ViewChannel))
|
|
543
|
+
throw new CreatorInviteOperationError("invalid_channel");
|
|
544
|
+
if (input.roleId) {
|
|
545
|
+
validateId(input.roleId);
|
|
546
|
+
const permissions = this.services.get(HELYX_SERVICE_NAMES.permissions);
|
|
547
|
+
if (!permissions.getGuildAccessPolicy)
|
|
548
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
549
|
+
const policy = await permissions.getGuildAccessPolicy(guild.id);
|
|
550
|
+
const role = await this.services
|
|
551
|
+
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
552
|
+
.inspectRole({
|
|
553
|
+
guildId: guild.id,
|
|
554
|
+
roleId: input.roleId,
|
|
555
|
+
actorUserId: input.actorUserId,
|
|
556
|
+
});
|
|
557
|
+
if (!isPublicCreatorRoleEligible(role, policy))
|
|
558
|
+
throw new CreatorInviteOperationError("invalid_role");
|
|
559
|
+
const liveRole = await guild.roles.fetch(input.roleId);
|
|
560
|
+
if (!liveRole ||
|
|
561
|
+
(liveRole.permissions.bitfield & ~me.permissions.bitfield) !== 0n)
|
|
562
|
+
throw new CreatorInviteOperationError("invalid_role");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async #enabled(guildId) {
|
|
566
|
+
if (!this.module)
|
|
567
|
+
return false;
|
|
568
|
+
return ((await this.services
|
|
569
|
+
.get(HELYX_SERVICE_NAMES.installations)
|
|
570
|
+
.isModuleEnabled(guildId, MODULE_ID)) &&
|
|
571
|
+
(await this.services
|
|
572
|
+
.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
573
|
+
.isAvailable(MODULE_ID)) &&
|
|
574
|
+
(await canAccessModule(this.services, guildId, this.module)));
|
|
575
|
+
}
|
|
576
|
+
async #authorise(guild, actorUserId) {
|
|
577
|
+
if (!(await this.#enabled(guild.id)))
|
|
578
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
579
|
+
const member = await guild.members.fetch({
|
|
580
|
+
user: actorUserId,
|
|
581
|
+
force: true,
|
|
582
|
+
});
|
|
583
|
+
const permitted = await this.services
|
|
584
|
+
.get(HELYX_SERVICE_NAMES.resourceAuthorization)
|
|
585
|
+
.canUse({
|
|
586
|
+
guildId: guild.id,
|
|
587
|
+
moduleId: MODULE_ID,
|
|
588
|
+
permissionId: "creator-connections.manage",
|
|
589
|
+
actor: {
|
|
590
|
+
userId: member.id,
|
|
591
|
+
roleIds: [...member.roles.cache.keys()],
|
|
592
|
+
permissions: member.permissions
|
|
593
|
+
.toArray()
|
|
594
|
+
.map(normalizeDiscordPermission),
|
|
595
|
+
isServerOwner: member.id === guild.ownerId,
|
|
596
|
+
isAdministrator: member.permissions.has(PermissionFlagsBits.Administrator),
|
|
597
|
+
highestRolePosition: member.roles.highest.position,
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
if (!permitted)
|
|
601
|
+
throw new CreatorInviteOperationError("forbidden");
|
|
602
|
+
}
|
|
603
|
+
#ownedGuild(guildId) {
|
|
604
|
+
const guild = this.client.guilds.cache.get(guildId);
|
|
605
|
+
if (!this.client.isReady() || !guild?.available)
|
|
606
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
607
|
+
return guild;
|
|
608
|
+
}
|
|
609
|
+
#discord() {
|
|
610
|
+
if (!this.client.token)
|
|
611
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
612
|
+
// Only this adapter disables transport retries; ordinary bot REST behaviour is unchanged.
|
|
613
|
+
this.#rest ??= new REST({
|
|
614
|
+
...discordRestOptions,
|
|
615
|
+
retries: 0,
|
|
616
|
+
timeout: 15_000,
|
|
617
|
+
}).setToken(this.client.token);
|
|
618
|
+
return this.#rest;
|
|
619
|
+
}
|
|
620
|
+
async #list(channelId) {
|
|
621
|
+
return (await this.#discord().get(Routes.channelInvites(channelId)));
|
|
622
|
+
}
|
|
623
|
+
#fingerprint(code) {
|
|
624
|
+
return createHmac("sha256", this.#key).update(code).digest("base64url");
|
|
625
|
+
}
|
|
626
|
+
#encrypt(record, code) {
|
|
627
|
+
const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", this.#key, iv);
|
|
628
|
+
cipher.setAAD(Buffer.from(`${record.guildId}:${record.reference}:${record.purpose}`));
|
|
629
|
+
const data = Buffer.concat([cipher.update(code, "utf8"), cipher.final()]);
|
|
630
|
+
return JSON.stringify({
|
|
631
|
+
v: 1,
|
|
632
|
+
iv: iv.toString("base64"),
|
|
633
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
634
|
+
data: data.toString("base64"),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
#decrypt(record) {
|
|
638
|
+
const sealed = JSON.parse(record.encryptedCode);
|
|
639
|
+
if (sealed.v !== 1)
|
|
640
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
641
|
+
const decipher = createDecipheriv("aes-256-gcm", this.#key, Buffer.from(sealed.iv, "base64"));
|
|
642
|
+
decipher.setAAD(Buffer.from(`${record.guildId}:${record.reference}:${record.purpose}`));
|
|
643
|
+
decipher.setAuthTag(Buffer.from(sealed.tag, "base64"));
|
|
644
|
+
const code = Buffer.concat([
|
|
645
|
+
decipher.update(Buffer.from(sealed.data, "base64")),
|
|
646
|
+
decipher.final(),
|
|
647
|
+
]).toString("utf8");
|
|
648
|
+
if (!inviteCode.test(code))
|
|
649
|
+
throw new CreatorInviteOperationError("unavailable");
|
|
650
|
+
return code;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function publicInvite(record) {
|
|
654
|
+
return {
|
|
655
|
+
reference: record.reference,
|
|
656
|
+
guildId: record.guildId,
|
|
657
|
+
channelId: record.channelId,
|
|
658
|
+
roleId: record.roleId,
|
|
659
|
+
purpose: record.purpose,
|
|
660
|
+
state: ["creating", "rejected"].includes(record.state)
|
|
661
|
+
? "unknown"
|
|
662
|
+
: record.state,
|
|
663
|
+
regenerateAfter: record.regenerateAfter?.toISOString() ?? null,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
function validateId(value) {
|
|
667
|
+
if (!snowflake.test(value))
|
|
668
|
+
throw new CreatorInviteOperationError("forbidden");
|
|
669
|
+
}
|
|
670
|
+
function validateReference(value) {
|
|
671
|
+
if (!uuid.test(value))
|
|
672
|
+
throw new CreatorInviteOperationError("not_owned");
|
|
673
|
+
}
|
|
674
|
+
function unknownInvite(error) {
|
|
675
|
+
return (typeof error === "object" &&
|
|
676
|
+
error !== null &&
|
|
677
|
+
"code" in error &&
|
|
678
|
+
error.code === RESTJSONErrorCodes.UnknownInvite);
|
|
679
|
+
}
|
|
680
|
+
function unknownChannel(error) {
|
|
681
|
+
return (typeof error === "object" &&
|
|
682
|
+
error !== null &&
|
|
683
|
+
"code" in error &&
|
|
684
|
+
error.code === RESTJSONErrorCodes.UnknownChannel);
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=creator-invite-service.js.map
|