@helyx/bot 1.0.1 → 1.1.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 +18 -0
- package/README.md +11 -1
- package/dist/developer-operations-module.d.ts +2 -0
- package/dist/developer-operations-module.js +136 -2
- package/dist/events.js +32 -5
- package/dist/guilds.d.ts +2 -1
- package/dist/guilds.js +158 -5
- package/dist/hosted-server-moderation.d.ts +89 -0
- package/dist/hosted-server-moderation.js +198 -0
- package/dist/index.js +23 -4
- package/dist/premium-activation-module.js +2 -0
- package/dist/shard-supervisor.js +6 -3
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +21 -1
- package/documentation/examples/.env.example +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add the hosted-server safety projection, durable leave delivery, owner
|
|
8
|
+
observation, hosted blacklist enforcement, internal moderation commands, and
|
|
9
|
+
the explicitly disabled self-host environment contract.
|
|
10
|
+
- Enforce the private Bot Invite incident gate for new hosted guild joins,
|
|
11
|
+
including fail-closed API handling and bounded leave retries.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Resolve bounded member display-name sets from cache first and use one Gateway
|
|
16
|
+
batch for remaining IDs when the Guild Members capability is available.
|
|
17
|
+
- Updated dependencies
|
|
18
|
+
- @helyx/database@2.1.0
|
|
19
|
+
- @helyx/config@1.1.0
|
|
20
|
+
|
|
3
21
|
## 1.0.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -111,13 +111,23 @@ production budget.
|
|
|
111
111
|
exact internal guild. `APPROVED_ROLES` is a de-duplicated, comma-separated
|
|
112
112
|
list of at most 100 Discord role IDs. A caller must have Discord's
|
|
113
113
|
Administrator permission or at least one approved role; role names are never
|
|
114
|
-
used for authorization. Every response is ephemeral
|
|
114
|
+
used for authorization. Every response is ephemeral. The default diagnostic
|
|
115
|
+
paths are read-only:
|
|
115
116
|
|
|
116
117
|
- `/dev shards` reports bounded shard health, including the unsharded
|
|
117
118
|
compatibility baseline;
|
|
118
119
|
- `/dev bot` reports the bot build and bounded installed-module versions;
|
|
119
120
|
- `/dev proc` reports safe metrics for the process handling the command.
|
|
120
121
|
|
|
122
|
+
On the official hosted bot only,
|
|
123
|
+
`HELYX_HOSTED_MODERATION_ENABLED=true` adds `/dev leave` and `/dev blacklist`.
|
|
124
|
+
They require the existing bot-control secret, a valid target snowflake and a
|
|
125
|
+
bounded private reason. The API records the action and delivers it durably;
|
|
126
|
+
there is no `/dev unblacklist`. The same hosted-only boundary enforces the
|
|
127
|
+
private Bot Invite incident gate before a newly joined server is initialised.
|
|
128
|
+
Self-hosted deployments must keep the default `false` value and never contact
|
|
129
|
+
the hosted Admin control plane.
|
|
130
|
+
|
|
121
131
|
No `/dev` command exposes tokens, URLs, Redis keys, database details,
|
|
122
132
|
environment variables or process arguments. Future developer subcommands must
|
|
123
133
|
extend this guild-only `/dev` root and reuse the same authorization boundary.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ModuleDefinition } from "@helyx/sdk";
|
|
2
2
|
import { type ShardHealthSnapshot } from "./shard-contracts.js";
|
|
3
3
|
import type { TriviaDataProvider } from "./trivia-data-provider.js";
|
|
4
|
+
import type { DeveloperServerModeration } from "./hosted-server-moderation.js";
|
|
4
5
|
export interface ShardHealthProvider {
|
|
5
6
|
getHealth(): Promise<ShardHealthSnapshot>;
|
|
6
7
|
}
|
|
@@ -19,6 +20,7 @@ export interface DeveloperOperationsInput {
|
|
|
19
20
|
processLabel: string;
|
|
20
21
|
health: ShardHealthProvider;
|
|
21
22
|
triviaData: TriviaDataProvider;
|
|
23
|
+
moderation?: DeveloperServerModeration;
|
|
22
24
|
}
|
|
23
25
|
export declare function createDeveloperOperationsModule(input: DeveloperOperationsInput): ModuleDefinition;
|
|
24
26
|
export declare class WorkerShardHealthProvider implements ShardHealthProvider {
|
|
@@ -4,7 +4,7 @@ import { SHARD_CONTROL_TIMEOUT_MS, assertShardMessageSize, shardOperationsHealth
|
|
|
4
4
|
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
5
5
|
const DEVELOPER_MODULE_ID = "helyx.developer-operations";
|
|
6
6
|
const DEVELOPER_MODULE_NAME = "Helyx Developer Operations";
|
|
7
|
-
const DEVELOPER_MODULE_VERSION = "0.
|
|
7
|
+
const DEVELOPER_MODULE_VERSION = "0.3.0";
|
|
8
8
|
const MAX_MODULE_LINES = 8;
|
|
9
9
|
export function createDeveloperOperationsModule(input) {
|
|
10
10
|
const approvedRoles = new Set(input.approvedRoleIds);
|
|
@@ -15,13 +15,24 @@ export function createDeveloperOperationsModule(input) {
|
|
|
15
15
|
id: DEVELOPER_MODULE_ID,
|
|
16
16
|
name: DEVELOPER_MODULE_NAME,
|
|
17
17
|
version: DEVELOPER_MODULE_VERSION,
|
|
18
|
-
description: "Private
|
|
18
|
+
description: "Private development and hosted-server operations for Helyx staff.",
|
|
19
19
|
entry: "./dist/developer-operations-module.js",
|
|
20
20
|
framework: "^0.1.0",
|
|
21
21
|
dependencies: [],
|
|
22
22
|
permissions: [],
|
|
23
23
|
contributions: { commands: ["dev"], events: [] },
|
|
24
24
|
},
|
|
25
|
+
defaultPermissions: {
|
|
26
|
+
module: restrictedPolicy(),
|
|
27
|
+
commands: Object.fromEntries([
|
|
28
|
+
"dev.shards",
|
|
29
|
+
"dev.bot",
|
|
30
|
+
"dev.proc",
|
|
31
|
+
"dev.trivia-data",
|
|
32
|
+
"dev.leave",
|
|
33
|
+
"dev.blacklist",
|
|
34
|
+
].map((id) => [id, restrictedPolicy()])),
|
|
35
|
+
},
|
|
25
36
|
interactions: {
|
|
26
37
|
commands: [
|
|
27
38
|
{
|
|
@@ -33,6 +44,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
33
44
|
subcommands: [
|
|
34
45
|
{
|
|
35
46
|
name: "shards",
|
|
47
|
+
permissionId: "dev.shards",
|
|
36
48
|
description: "Show bounded read-only shard health",
|
|
37
49
|
acknowledgement: "deferred-ephemeral",
|
|
38
50
|
authorization: "self-authorized",
|
|
@@ -83,6 +95,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
83
95
|
},
|
|
84
96
|
{
|
|
85
97
|
name: "bot",
|
|
98
|
+
permissionId: "dev.bot",
|
|
86
99
|
description: "Show the bot build and installed module versions",
|
|
87
100
|
acknowledgement: "immediate-response",
|
|
88
101
|
authorization: "self-authorized",
|
|
@@ -137,6 +150,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
137
150
|
},
|
|
138
151
|
{
|
|
139
152
|
name: "proc",
|
|
153
|
+
permissionId: "dev.proc",
|
|
140
154
|
description: "Show safe process resource and uptime information",
|
|
141
155
|
acknowledgement: "immediate-response",
|
|
142
156
|
authorization: "self-authorized",
|
|
@@ -171,6 +185,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
171
185
|
},
|
|
172
186
|
{
|
|
173
187
|
name: "trivia-data",
|
|
188
|
+
permissionId: "dev.trivia-data",
|
|
174
189
|
description: "Show question counts from the Trivia database",
|
|
175
190
|
acknowledgement: "deferred-ephemeral",
|
|
176
191
|
authorization: "self-authorized",
|
|
@@ -220,6 +235,100 @@ export function createDeveloperOperationsModule(input) {
|
|
|
220
235
|
});
|
|
221
236
|
},
|
|
222
237
|
},
|
|
238
|
+
...(input.moderation
|
|
239
|
+
? ["leave", "blacklist"].map((action) => ({
|
|
240
|
+
name: action,
|
|
241
|
+
permissionId: `dev.${action}`,
|
|
242
|
+
description: action === "leave"
|
|
243
|
+
? "Make hosted Helyx leave a server"
|
|
244
|
+
: "Blacklist a server and make hosted Helyx leave",
|
|
245
|
+
acknowledgement: "deferred-ephemeral",
|
|
246
|
+
authorization: "self-authorized",
|
|
247
|
+
rateLimit: {
|
|
248
|
+
scope: "user",
|
|
249
|
+
limit: 3,
|
|
250
|
+
windowSeconds: 60,
|
|
251
|
+
},
|
|
252
|
+
options: [
|
|
253
|
+
{
|
|
254
|
+
type: "string",
|
|
255
|
+
name: "server_id",
|
|
256
|
+
description: "Discord server ID",
|
|
257
|
+
required: true,
|
|
258
|
+
minLength: 17,
|
|
259
|
+
maxLength: 20,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
type: "string",
|
|
263
|
+
name: "reason",
|
|
264
|
+
description: "Private reason for this action",
|
|
265
|
+
required: true,
|
|
266
|
+
minLength: 3,
|
|
267
|
+
maxLength: 500,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
async execute(context) {
|
|
271
|
+
if (!(await authorizeDeveloperCommand(context)))
|
|
272
|
+
return;
|
|
273
|
+
const guildId = context
|
|
274
|
+
.getStringOption("server_id")
|
|
275
|
+
?.trim();
|
|
276
|
+
const reason = context.getStringOption("reason")?.trim();
|
|
277
|
+
if (!guildId ||
|
|
278
|
+
!/^\d{17,20}$/u.test(guildId) ||
|
|
279
|
+
!reason ||
|
|
280
|
+
reason.length < 3 ||
|
|
281
|
+
reason.length > 500 ||
|
|
282
|
+
/[\r\n\0]/u.test(reason)) {
|
|
283
|
+
await context.reply({
|
|
284
|
+
ephemeral: true,
|
|
285
|
+
componentsV2: {
|
|
286
|
+
accentColor: 0xdc2626,
|
|
287
|
+
text: [
|
|
288
|
+
{
|
|
289
|
+
content: "The server ID or private reason is invalid.",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const result = await input.moderation.moderate({
|
|
298
|
+
action,
|
|
299
|
+
guildId,
|
|
300
|
+
actorUserId: context.userId,
|
|
301
|
+
reason,
|
|
302
|
+
idempotencyKey: `discord:${context.interactionId}`,
|
|
303
|
+
});
|
|
304
|
+
await context.reply({
|
|
305
|
+
ephemeral: true,
|
|
306
|
+
componentsV2: {
|
|
307
|
+
accentColor: 0x2563eb,
|
|
308
|
+
text: [
|
|
309
|
+
{
|
|
310
|
+
content: `## Hosted server ${action}\n${moderationOutcome(result.outcome)}\nOperation: \`${result.operationId}\`${result.paidSlotsReleased ? `\nPremium slots released: ${result.paidSlotsReleased}` : ""}`,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
await context.reply({
|
|
318
|
+
ephemeral: true,
|
|
319
|
+
componentsV2: {
|
|
320
|
+
accentColor: 0xdc2626,
|
|
321
|
+
text: [
|
|
322
|
+
{
|
|
323
|
+
content: "The hosted moderation service is unavailable. The request was not reported as complete; retry with the same command when service is restored.",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
}))
|
|
331
|
+
: []),
|
|
223
332
|
],
|
|
224
333
|
},
|
|
225
334
|
],
|
|
@@ -247,6 +356,31 @@ export function createDeveloperOperationsModule(input) {
|
|
|
247
356
|
return false;
|
|
248
357
|
}
|
|
249
358
|
}
|
|
359
|
+
function restrictedPolicy() {
|
|
360
|
+
return {
|
|
361
|
+
everyone: false,
|
|
362
|
+
requiredPermissions: ["ADMINISTRATOR"],
|
|
363
|
+
allowedRoleIds: [],
|
|
364
|
+
deniedRoleIds: [],
|
|
365
|
+
allowedUserIds: [],
|
|
366
|
+
deniedUserIds: [],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function moderationOutcome(outcome) {
|
|
370
|
+
if (outcome === "queued")
|
|
371
|
+
return "Accepted. Helyx will leave through the durable delivery queue.";
|
|
372
|
+
if (outcome === "already_blacklisted")
|
|
373
|
+
return "No new blacklist was created; this server is already blacklisted.";
|
|
374
|
+
if (outcome === "not_currently_installed")
|
|
375
|
+
return "Recorded. Helyx is not currently installed in that server.";
|
|
376
|
+
if (outcome === "accepted_for_retry")
|
|
377
|
+
return "Accepted for a safe retry.";
|
|
378
|
+
if (outcome === "requires_review")
|
|
379
|
+
return "Recorded and flagged for staff review.";
|
|
380
|
+
if (outcome === "completed")
|
|
381
|
+
return "Completed.";
|
|
382
|
+
return "The request was recorded but did not complete.";
|
|
383
|
+
}
|
|
250
384
|
function safeInlineCode(value) {
|
|
251
385
|
return `\`${value.replaceAll("`", "'").slice(0, 120)}\``;
|
|
252
386
|
}
|
package/dist/events.js
CHANGED
|
@@ -1504,19 +1504,46 @@ export class DiscordResourceInspection {
|
|
|
1504
1504
|
}
|
|
1505
1505
|
async resolveMembers(input) {
|
|
1506
1506
|
const guild = await this.#resolveMemberGuild(input);
|
|
1507
|
-
|
|
1508
|
-
|
|
1507
|
+
const members = new Map();
|
|
1508
|
+
for (const userId of input.userIds) {
|
|
1509
|
+
const cached = guild.members.cache?.get(userId);
|
|
1510
|
+
if (cached)
|
|
1511
|
+
members.set(userId, cached);
|
|
1512
|
+
}
|
|
1513
|
+
const missing = input.userIds.filter((userId) => !members.has(userId));
|
|
1514
|
+
const canBulkResolve = this.client.options?.intents?.has(GatewayIntentBits.GuildMembers);
|
|
1515
|
+
if (missing.length > 0 && canBulkResolve) {
|
|
1516
|
+
const fetched = await guild.members
|
|
1517
|
+
.fetch({ user: missing, time: 5_000 })
|
|
1518
|
+
.catch(() => null);
|
|
1519
|
+
if (fetched)
|
|
1520
|
+
for (const member of fetched.values())
|
|
1521
|
+
if (missing.includes(member.id))
|
|
1522
|
+
members.set(member.id, member);
|
|
1523
|
+
}
|
|
1524
|
+
else if (missing.length > 0) {
|
|
1525
|
+
const fetched = await Promise.all(missing.map((userId) => fetchExactMember(guild, userId, "member").catch((error) => {
|
|
1509
1526
|
if (isUnknownMember(error))
|
|
1510
1527
|
return null;
|
|
1511
1528
|
throw error;
|
|
1512
|
-
});
|
|
1529
|
+
})));
|
|
1530
|
+
for (const member of fetched)
|
|
1531
|
+
if (member)
|
|
1532
|
+
members.set(member.id, member);
|
|
1533
|
+
}
|
|
1534
|
+
return input.userIds.map((userId) => {
|
|
1535
|
+
const member = members.get(userId) ?? null;
|
|
1536
|
+
const cachedUser = this.client.users?.cache?.get(userId);
|
|
1513
1537
|
const avatarUrl = memberAvatarUrl(member);
|
|
1514
1538
|
return {
|
|
1515
1539
|
userId,
|
|
1516
|
-
displayName: member?.displayName ??
|
|
1540
|
+
displayName: member?.displayName ??
|
|
1541
|
+
cachedUser?.globalName ??
|
|
1542
|
+
cachedUser?.username ??
|
|
1543
|
+
null,
|
|
1517
1544
|
...(avatarUrl ? { avatarUrl } : {}),
|
|
1518
1545
|
};
|
|
1519
|
-
})
|
|
1546
|
+
});
|
|
1520
1547
|
}
|
|
1521
1548
|
async searchMembers(input) {
|
|
1522
1549
|
validateSnowflake(input.guildId, "guild");
|
package/dist/guilds.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
|
|
|
2
2
|
import type { ModuleDefinition } from "@helyx/sdk";
|
|
3
3
|
import { type Client } from "discord.js";
|
|
4
4
|
import type { InteractionLogger } from "./interactions.js";
|
|
5
|
+
import type { HostedBotInviteGate, HostedGuildSafety } from "./hosted-server-moderation.js";
|
|
5
6
|
export interface GuildReconciliationHealth {
|
|
6
7
|
status: "pending" | "running" | "healthy" | "failed" | "stopped";
|
|
7
8
|
lastStartedAt?: string;
|
|
@@ -21,5 +22,5 @@ export interface GuildTrackingPreparation {
|
|
|
21
22
|
health(): GuildReconciliationHealth;
|
|
22
23
|
stop(): void;
|
|
23
24
|
}
|
|
24
|
-
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string): GuildTrackingPreparation;
|
|
25
|
+
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string, hostedSafety?: HostedGuildSafety, hostedInviteGate?: HostedBotInviteGate): GuildTrackingPreparation;
|
|
25
26
|
//# sourceMappingURL=guilds.d.ts.map
|
package/dist/guilds.js
CHANGED
|
@@ -3,7 +3,7 @@ import { safeError } from "@helyx/core";
|
|
|
3
3
|
import { Events } from "discord.js";
|
|
4
4
|
const RECONCILIATION_PAGE_SIZE = 250;
|
|
5
5
|
const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
|
|
6
|
-
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId) {
|
|
6
|
+
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId, hostedSafety, hostedInviteGate) {
|
|
7
7
|
if (!Number.isSafeInteger(reconciliationIntervalMs) ||
|
|
8
8
|
reconciliationIntervalMs < 60_000)
|
|
9
9
|
throw new Error("Guild reconciliation interval must be at least one minute");
|
|
@@ -12,6 +12,11 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
12
12
|
let stopped = false;
|
|
13
13
|
let reconciliation;
|
|
14
14
|
let timer;
|
|
15
|
+
const admissionRetries = new Map();
|
|
16
|
+
const inviteGateRejections = new Map();
|
|
17
|
+
const ownerObservations = hostedSafety
|
|
18
|
+
? new OwnerObservationQueue(hostedSafety, logger)
|
|
19
|
+
: undefined;
|
|
15
20
|
let health = {
|
|
16
21
|
status: "pending",
|
|
17
22
|
examinedRows: 0,
|
|
@@ -57,17 +62,102 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
57
62
|
await withTransaction(database, persist);
|
|
58
63
|
else
|
|
59
64
|
await persist(database);
|
|
65
|
+
ownerObservations?.enqueue(guild);
|
|
66
|
+
}
|
|
67
|
+
async function leaveInviteRejectedGuild(guild) {
|
|
68
|
+
const reasonCode = inviteGateRejections.get(guild.id);
|
|
69
|
+
if (!reasonCode)
|
|
70
|
+
return;
|
|
71
|
+
await guild.leave();
|
|
72
|
+
logger.info({
|
|
73
|
+
event: "guild.invite_gate_enforced",
|
|
74
|
+
guildId: guild.id,
|
|
75
|
+
reasonCode,
|
|
76
|
+
}, "New guild was rejected by the Bot Invite gate");
|
|
77
|
+
}
|
|
78
|
+
async function admitAndSync(guild, firstObservation) {
|
|
79
|
+
if (inviteGateRejections.has(guild.id)) {
|
|
80
|
+
await leaveInviteRejectedGuild(guild);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const [blacklist, inviteGate] = await Promise.allSettled([
|
|
84
|
+
hostedSafety
|
|
85
|
+
? hostedSafety.isBlacklisted(guild.id)
|
|
86
|
+
: Promise.resolve(false),
|
|
87
|
+
hostedInviteGate ? hostedInviteGate.isEnabled() : Promise.resolve(true),
|
|
88
|
+
]);
|
|
89
|
+
if (hostedSafety && blacklist.status === "fulfilled" && blacklist.value) {
|
|
90
|
+
const enforcement = await Promise.allSettled([
|
|
91
|
+
hostedSafety.recordAutomaticLeave(guild.id),
|
|
92
|
+
guild.leave(),
|
|
93
|
+
]);
|
|
94
|
+
if (enforcement.some((result) => result.status === "rejected"))
|
|
95
|
+
throw new Error("Blacklisted guild enforcement was unavailable");
|
|
96
|
+
logger.info({ event: "guild.blacklist_enforced", guildId: guild.id }, "Blacklisted guild was rejected before initialization");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (inviteGate.status === "rejected") {
|
|
100
|
+
inviteGateRejections.set(guild.id, "gate_unavailable");
|
|
101
|
+
logger.warn({
|
|
102
|
+
event: "guild.invite_gate_check_failed",
|
|
103
|
+
guildId: guild.id,
|
|
104
|
+
reasonCode: "gate_unavailable",
|
|
105
|
+
}, "Bot Invite gate was unavailable; the new guild will be rejected");
|
|
106
|
+
await leaveInviteRejectedGuild(guild);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!inviteGate.value) {
|
|
110
|
+
inviteGateRejections.set(guild.id, "gate_paused");
|
|
111
|
+
await leaveInviteRejectedGuild(guild);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (blacklist.status === "rejected")
|
|
115
|
+
throw new Error("Hosted guild safety lookup was unavailable");
|
|
116
|
+
await syncGuild(guild, firstObservation);
|
|
117
|
+
const retry = admissionRetries.get(guild.id);
|
|
118
|
+
if (retry)
|
|
119
|
+
clearTimeout(retry);
|
|
120
|
+
admissionRetries.delete(guild.id);
|
|
121
|
+
}
|
|
122
|
+
function scheduleAdmissionRetry(guild) {
|
|
123
|
+
if (stopped || admissionRetries.has(guild.id))
|
|
124
|
+
return;
|
|
125
|
+
const retry = setTimeout(() => {
|
|
126
|
+
admissionRetries.delete(guild.id);
|
|
127
|
+
if (stopped || !guild.available)
|
|
128
|
+
return;
|
|
129
|
+
void admitAndSync(guild, true).catch(() => {
|
|
130
|
+
logger.error({
|
|
131
|
+
event: "guild.admission_retry_failed",
|
|
132
|
+
guildId: guild.id,
|
|
133
|
+
reasonCode: "admission_unavailable",
|
|
134
|
+
}, "Guild admission retry failed");
|
|
135
|
+
scheduleAdmissionRetry(guild);
|
|
136
|
+
});
|
|
137
|
+
}, 30_000);
|
|
138
|
+
retry.unref();
|
|
139
|
+
admissionRetries.set(guild.id, retry);
|
|
60
140
|
}
|
|
61
141
|
client.on(Events.GuildCreate, (guild) => {
|
|
62
|
-
void
|
|
142
|
+
void admitAndSync(guild, true).catch(() => {
|
|
63
143
|
logger.error({
|
|
64
|
-
event: "guild.
|
|
144
|
+
event: "guild.admission_failed",
|
|
65
145
|
guildId: guild.id,
|
|
66
|
-
|
|
67
|
-
}, "Guild
|
|
146
|
+
reasonCode: "admission_unavailable",
|
|
147
|
+
}, "Guild admission failed closed");
|
|
148
|
+
scheduleAdmissionRetry(guild);
|
|
68
149
|
});
|
|
69
150
|
});
|
|
151
|
+
client.on(Events.GuildUpdate, (_previous, guild) => {
|
|
152
|
+
if (!inviteGateRejections.has(guild.id))
|
|
153
|
+
ownerObservations?.enqueue(guild);
|
|
154
|
+
});
|
|
70
155
|
client.on(Events.GuildDelete, (guild) => {
|
|
156
|
+
const retry = admissionRetries.get(guild.id);
|
|
157
|
+
if (retry)
|
|
158
|
+
clearTimeout(retry);
|
|
159
|
+
admissionRetries.delete(guild.id);
|
|
160
|
+
inviteGateRejections.delete(guild.id);
|
|
71
161
|
void repository
|
|
72
162
|
.markGuildRemoved(guild.id, ownership)
|
|
73
163
|
.then(async (changed) => {
|
|
@@ -110,9 +200,23 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
110
200
|
const observedGuildIds = new Set(client.guilds.cache.keys());
|
|
111
201
|
const unavailableGuilds = [...client.guilds.cache.values()].filter((guild) => !guild.available).length;
|
|
112
202
|
try {
|
|
203
|
+
const blacklistedGuildIds = new Set();
|
|
204
|
+
if (hostedSafety) {
|
|
205
|
+
const observed = [...observedGuildIds];
|
|
206
|
+
for (let offset = 0; offset < observed.length; offset += RECONCILIATION_PAGE_SIZE) {
|
|
207
|
+
const page = await hostedSafety.blacklistedGuildIds(observed.slice(offset, offset + RECONCILIATION_PAGE_SIZE));
|
|
208
|
+
for (const guildId of page)
|
|
209
|
+
blacklistedGuildIds.add(guildId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
113
212
|
for (const guild of client.guilds.cache.values()) {
|
|
114
213
|
if (stopped)
|
|
115
214
|
return;
|
|
215
|
+
if (blacklistedGuildIds.has(guild.id)) {
|
|
216
|
+
await hostedSafety.recordAutomaticLeave(guild.id);
|
|
217
|
+
await guild.leave();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
116
220
|
await syncGuild(guild);
|
|
117
221
|
refreshedGuilds += 1;
|
|
118
222
|
}
|
|
@@ -211,8 +315,57 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
211
315
|
if (timer)
|
|
212
316
|
clearInterval(timer);
|
|
213
317
|
timer = undefined;
|
|
318
|
+
for (const retry of admissionRetries.values())
|
|
319
|
+
clearTimeout(retry);
|
|
320
|
+
admissionRetries.clear();
|
|
321
|
+
inviteGateRejections.clear();
|
|
322
|
+
ownerObservations?.stop();
|
|
214
323
|
health = { ...health, status: "stopped", nextScheduledAt: undefined };
|
|
215
324
|
};
|
|
216
325
|
return prepare;
|
|
217
326
|
}
|
|
327
|
+
class OwnerObservationQueue {
|
|
328
|
+
safety;
|
|
329
|
+
logger;
|
|
330
|
+
#pending = new Map();
|
|
331
|
+
#active = 0;
|
|
332
|
+
#stopped = false;
|
|
333
|
+
constructor(safety, logger) {
|
|
334
|
+
this.safety = safety;
|
|
335
|
+
this.logger = logger;
|
|
336
|
+
}
|
|
337
|
+
enqueue(guild) {
|
|
338
|
+
if (this.#stopped)
|
|
339
|
+
return;
|
|
340
|
+
this.#pending.set(guild.id, guild);
|
|
341
|
+
this.#drain();
|
|
342
|
+
}
|
|
343
|
+
stop() {
|
|
344
|
+
this.#stopped = true;
|
|
345
|
+
this.#pending.clear();
|
|
346
|
+
}
|
|
347
|
+
#drain() {
|
|
348
|
+
while (!this.#stopped && this.#active < 2 && this.#pending.size > 0) {
|
|
349
|
+
const next = this.#pending.entries().next().value;
|
|
350
|
+
if (!next)
|
|
351
|
+
return;
|
|
352
|
+
const [guildId, guild] = next;
|
|
353
|
+
this.#pending.delete(guildId);
|
|
354
|
+
this.#active += 1;
|
|
355
|
+
void this.safety
|
|
356
|
+
.observeOwner(guild)
|
|
357
|
+
.catch(() => {
|
|
358
|
+
this.logger.warn({
|
|
359
|
+
event: "guild.owner_observation_failed",
|
|
360
|
+
guildId,
|
|
361
|
+
reasonCode: "owner_identity_unavailable",
|
|
362
|
+
}, "Guild owner identity observation was deferred");
|
|
363
|
+
})
|
|
364
|
+
.finally(() => {
|
|
365
|
+
this.#active -= 1;
|
|
366
|
+
this.#drain();
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
218
371
|
//# sourceMappingURL=guilds.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type DatabasePool } from "@helyx/database";
|
|
2
|
+
import type { Client, Guild } from "discord.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { InteractionLogger } from "./interactions.js";
|
|
5
|
+
declare const resultSchema: z.ZodObject<{
|
|
6
|
+
operationId: z.ZodUUID;
|
|
7
|
+
guildId: z.ZodString;
|
|
8
|
+
action: z.ZodEnum<{
|
|
9
|
+
leave: "leave";
|
|
10
|
+
blacklist: "blacklist";
|
|
11
|
+
}>;
|
|
12
|
+
outcome: z.ZodEnum<{
|
|
13
|
+
completed: "completed";
|
|
14
|
+
requires_review: "requires_review";
|
|
15
|
+
failed: "failed";
|
|
16
|
+
queued: "queued";
|
|
17
|
+
already_blacklisted: "already_blacklisted";
|
|
18
|
+
not_currently_installed: "not_currently_installed";
|
|
19
|
+
accepted_for_retry: "accepted_for_retry";
|
|
20
|
+
}>;
|
|
21
|
+
paidSlotsReleased: z.ZodNumber;
|
|
22
|
+
}, z.core.$loose>;
|
|
23
|
+
export interface DeveloperServerModeration {
|
|
24
|
+
moderate(input: {
|
|
25
|
+
action: "leave" | "blacklist";
|
|
26
|
+
guildId: string;
|
|
27
|
+
actorUserId: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
idempotencyKey: string;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
operationId: string;
|
|
32
|
+
outcome: z.infer<typeof resultSchema>["outcome"];
|
|
33
|
+
paidSlotsReleased: number;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
export declare class HostedServerModerationClient implements DeveloperServerModeration {
|
|
37
|
+
private readonly apiUrl;
|
|
38
|
+
private readonly secret;
|
|
39
|
+
constructor(apiUrl: string, secret: string);
|
|
40
|
+
moderate(input: {
|
|
41
|
+
action: "leave" | "blacklist";
|
|
42
|
+
guildId: string;
|
|
43
|
+
actorUserId: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
idempotencyKey: string;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
operationId: string;
|
|
48
|
+
outcome: "completed" | "requires_review" | "failed" | "queued" | "already_blacklisted" | "not_currently_installed" | "accepted_for_retry";
|
|
49
|
+
paidSlotsReleased: number;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export interface HostedBotInviteGate {
|
|
53
|
+
isEnabled(): Promise<boolean>;
|
|
54
|
+
}
|
|
55
|
+
export declare class HostedBotInviteGateClient implements HostedBotInviteGate {
|
|
56
|
+
private readonly apiUrl;
|
|
57
|
+
private readonly secret;
|
|
58
|
+
constructor(apiUrl: string, secret: string);
|
|
59
|
+
isEnabled(): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
export interface HostedGuildSafety {
|
|
62
|
+
isBlacklisted(guildId: string): Promise<boolean>;
|
|
63
|
+
blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
|
|
64
|
+
recordAutomaticLeave(guildId: string): Promise<string>;
|
|
65
|
+
observeOwner(guild: Guild): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
export declare class PostgresHostedGuildSafety implements HostedGuildSafety {
|
|
68
|
+
#private;
|
|
69
|
+
constructor(database: DatabasePool);
|
|
70
|
+
isBlacklisted(guildId: string): Promise<boolean>;
|
|
71
|
+
blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
|
|
72
|
+
recordAutomaticLeave(guildId: string): Promise<string>;
|
|
73
|
+
observeOwner(guild: Guild): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
export declare class HostedServerLeaveWorker {
|
|
76
|
+
#private;
|
|
77
|
+
private readonly client;
|
|
78
|
+
private readonly logger;
|
|
79
|
+
private readonly shardScope?;
|
|
80
|
+
constructor(database: DatabasePool, client: Client, logger: InteractionLogger, shardScope?: {
|
|
81
|
+
shardId: number;
|
|
82
|
+
shardCount: number;
|
|
83
|
+
} | undefined);
|
|
84
|
+
start(): void;
|
|
85
|
+
runOnce(): Promise<void>;
|
|
86
|
+
stop(): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
export {};
|
|
89
|
+
//# sourceMappingURL=hosted-server-moderation.d.ts.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { HostedServerSafetyRepository, } from "@helyx/database";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const resultSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
operationId: z.uuid(),
|
|
6
|
+
guildId: z.string().regex(/^\d{17,20}$/u),
|
|
7
|
+
action: z.enum(["leave", "blacklist"]),
|
|
8
|
+
outcome: z.enum([
|
|
9
|
+
"queued",
|
|
10
|
+
"completed",
|
|
11
|
+
"already_blacklisted",
|
|
12
|
+
"not_currently_installed",
|
|
13
|
+
"accepted_for_retry",
|
|
14
|
+
"requires_review",
|
|
15
|
+
"failed",
|
|
16
|
+
]),
|
|
17
|
+
paidSlotsReleased: z.number().int().nonnegative(),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
20
|
+
const botInviteGateSchema = z.object({ enabled: z.boolean() }).strict();
|
|
21
|
+
export class HostedServerModerationClient {
|
|
22
|
+
apiUrl;
|
|
23
|
+
secret;
|
|
24
|
+
constructor(apiUrl, secret) {
|
|
25
|
+
this.apiUrl = apiUrl;
|
|
26
|
+
this.secret = secret;
|
|
27
|
+
}
|
|
28
|
+
async moderate(input) {
|
|
29
|
+
const response = await fetch(new URL(`/v1/internal/hosted-server-moderation/${input.action}`, this.apiUrl), {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
authorization: `Bearer ${this.secret}`,
|
|
33
|
+
"content-type": "application/json",
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
guildId: input.guildId,
|
|
37
|
+
actorUserId: input.actorUserId,
|
|
38
|
+
reason: input.reason,
|
|
39
|
+
idempotencyKey: input.idempotencyKey,
|
|
40
|
+
}),
|
|
41
|
+
signal: AbortSignal.timeout(10_000),
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`Hosted moderation request failed with ${response.status}`);
|
|
45
|
+
const result = resultSchema.parse(await response.json());
|
|
46
|
+
if (result.guildId !== input.guildId || result.action !== input.action)
|
|
47
|
+
throw new Error("Hosted moderation response did not match the request");
|
|
48
|
+
return {
|
|
49
|
+
operationId: result.operationId,
|
|
50
|
+
outcome: result.outcome,
|
|
51
|
+
paidSlotsReleased: result.paidSlotsReleased,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class HostedBotInviteGateClient {
|
|
56
|
+
apiUrl;
|
|
57
|
+
secret;
|
|
58
|
+
constructor(apiUrl, secret) {
|
|
59
|
+
this.apiUrl = apiUrl;
|
|
60
|
+
this.secret = secret;
|
|
61
|
+
}
|
|
62
|
+
async isEnabled() {
|
|
63
|
+
const response = await fetch(new URL("/v1/internal/incident-gates/bot-invites", this.apiUrl), {
|
|
64
|
+
headers: { authorization: `Bearer ${this.secret}` },
|
|
65
|
+
signal: AbortSignal.timeout(3_000),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok)
|
|
68
|
+
throw new Error(`Bot Invite gate request failed with ${response.status}`);
|
|
69
|
+
return botInviteGateSchema.parse(await response.json()).enabled;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class PostgresHostedGuildSafety {
|
|
73
|
+
#repository;
|
|
74
|
+
constructor(database) {
|
|
75
|
+
this.#repository = new HostedServerSafetyRepository(database);
|
|
76
|
+
}
|
|
77
|
+
async isBlacklisted(guildId) {
|
|
78
|
+
return (await this.#repository.getState(guildId)).blacklisted;
|
|
79
|
+
}
|
|
80
|
+
blacklistedGuildIds(guildIds) {
|
|
81
|
+
return this.#repository.getBlacklistedGuildIds(guildIds);
|
|
82
|
+
}
|
|
83
|
+
recordAutomaticLeave(guildId) {
|
|
84
|
+
return this.#repository.enqueueAutomaticLeave(guildId);
|
|
85
|
+
}
|
|
86
|
+
async observeOwner(guild) {
|
|
87
|
+
const cached = guild.members.cache.get(guild.ownerId)?.user;
|
|
88
|
+
const user = cached ?? (await guild.client.users.fetch(guild.ownerId));
|
|
89
|
+
await this.#repository.observeOwner({
|
|
90
|
+
guildId: guild.id,
|
|
91
|
+
ownerUserId: guild.ownerId,
|
|
92
|
+
username: user.username,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export class HostedServerLeaveWorker {
|
|
97
|
+
client;
|
|
98
|
+
logger;
|
|
99
|
+
shardScope;
|
|
100
|
+
#repository;
|
|
101
|
+
#timer;
|
|
102
|
+
#active;
|
|
103
|
+
#stopped = false;
|
|
104
|
+
constructor(database, client, logger, shardScope) {
|
|
105
|
+
this.client = client;
|
|
106
|
+
this.logger = logger;
|
|
107
|
+
this.shardScope = shardScope;
|
|
108
|
+
this.#repository = new HostedServerSafetyRepository(database);
|
|
109
|
+
}
|
|
110
|
+
start() {
|
|
111
|
+
if (this.#timer || this.#stopped)
|
|
112
|
+
return;
|
|
113
|
+
this.#runSoon();
|
|
114
|
+
this.#timer = setInterval(() => this.#runSoon(), 15_000);
|
|
115
|
+
this.#timer.unref();
|
|
116
|
+
}
|
|
117
|
+
async runOnce() {
|
|
118
|
+
if (this.#stopped || this.#active)
|
|
119
|
+
return this.#active;
|
|
120
|
+
this.#active = this.#drain().finally(() => {
|
|
121
|
+
this.#active = undefined;
|
|
122
|
+
});
|
|
123
|
+
return this.#active;
|
|
124
|
+
}
|
|
125
|
+
async stop() {
|
|
126
|
+
this.#stopped = true;
|
|
127
|
+
if (this.#timer)
|
|
128
|
+
clearInterval(this.#timer);
|
|
129
|
+
this.#timer = undefined;
|
|
130
|
+
await this.#active;
|
|
131
|
+
}
|
|
132
|
+
async #drain() {
|
|
133
|
+
const claims = await this.#repository.claimLeaveDeliveries({
|
|
134
|
+
limit: 10,
|
|
135
|
+
leaseSeconds: 30,
|
|
136
|
+
...(this.shardScope ?? {}),
|
|
137
|
+
});
|
|
138
|
+
await Promise.all(claims.map((claim) => this.#deliver(claim)));
|
|
139
|
+
}
|
|
140
|
+
#runSoon() {
|
|
141
|
+
void this.runOnce().catch(() => {
|
|
142
|
+
this.logger.error({
|
|
143
|
+
event: "hosted_moderation.leave_claim_failed",
|
|
144
|
+
reasonCode: "database_unavailable",
|
|
145
|
+
}, "Hosted server leave claims are temporarily unavailable");
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async #deliver(claim) {
|
|
149
|
+
const guild = this.client.guilds.cache.get(claim.guildId);
|
|
150
|
+
if (!guild) {
|
|
151
|
+
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await guild.leave();
|
|
156
|
+
await this.#repository.completeLeaveDelivery(claim, "completed");
|
|
157
|
+
this.logger.info({
|
|
158
|
+
event: "hosted_moderation.leave_completed",
|
|
159
|
+
guildId: claim.guildId,
|
|
160
|
+
operationId: claim.operationId,
|
|
161
|
+
}, "Hosted server leave completed");
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const failureCode = classifyLeaveFailure(error);
|
|
165
|
+
if (failureCode === "discord_not_installed") {
|
|
166
|
+
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const outcome = await this.#repository.failLeaveDelivery({
|
|
170
|
+
claim,
|
|
171
|
+
failureCode,
|
|
172
|
+
retryAfterSeconds: Math.min(3_600, 15 * 2 ** claim.attemptCount),
|
|
173
|
+
});
|
|
174
|
+
this.logger.warn({
|
|
175
|
+
event: "hosted_moderation.leave_deferred",
|
|
176
|
+
guildId: claim.guildId,
|
|
177
|
+
operationId: claim.operationId,
|
|
178
|
+
failureCode,
|
|
179
|
+
outcome,
|
|
180
|
+
}, "Hosted server leave will be retried or reviewed");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function classifyLeaveFailure(error) {
|
|
185
|
+
if (typeof error !== "object" || error === null)
|
|
186
|
+
return "discord_transient";
|
|
187
|
+
const candidate = error;
|
|
188
|
+
const code = candidate.code;
|
|
189
|
+
if (code === 10_004 || code === 50_001)
|
|
190
|
+
return "discord_not_installed";
|
|
191
|
+
const status = candidate.status;
|
|
192
|
+
if (status === 401 || status === 403)
|
|
193
|
+
return "discord_authentication";
|
|
194
|
+
if (status === 429)
|
|
195
|
+
return "discord_rate_limited";
|
|
196
|
+
return "discord_transient";
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=hosted-server-moderation.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseBotEnvironment } from "@helyx/config";
|
|
2
2
|
import { StatusHeartbeatReporter } from "@helyx/status-client";
|
|
3
3
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
4
|
-
import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
|
|
4
|
+
import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
|
|
5
5
|
import { createRuntime } from "@helyx/framework";
|
|
6
6
|
import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
|
|
7
7
|
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
@@ -50,6 +50,7 @@ import { discordRestOptions } from "./discord-rest.js";
|
|
|
50
50
|
import { discordBotStatusService } from "./status-producer.js";
|
|
51
51
|
import { assertReviewedPremiumCatalogue, requirePremiumProviderEnvironment, } from "./premium-access.js";
|
|
52
52
|
import { enforcePremiumCatalogue, PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS, } from "./premium-catalogue-enforcement.js";
|
|
53
|
+
import { HostedBotInviteGateClient, HostedServerLeaveWorker, HostedServerModerationClient, PostgresHostedGuildSafety, } from "./hosted-server-moderation.js";
|
|
53
54
|
export { loadConfiguredModulePackages } from "./modules.js";
|
|
54
55
|
export { resolveModulePackageSpecifiers } from "./module-configuration.js";
|
|
55
56
|
export * from "./control-server.js";
|
|
@@ -119,6 +120,15 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
119
120
|
sslMode: environment.DATABASE_SSL,
|
|
120
121
|
applicationName: "helyx-bot",
|
|
121
122
|
});
|
|
123
|
+
const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
124
|
+
? new PostgresHostedGuildSafety(database)
|
|
125
|
+
: undefined;
|
|
126
|
+
const developerModeration = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
127
|
+
? new HostedServerModerationClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
|
|
128
|
+
: undefined;
|
|
129
|
+
const hostedInviteGate = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
130
|
+
? new HostedBotInviteGateClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
|
|
131
|
+
: undefined;
|
|
122
132
|
const privacyDatabase = environment.HELYX_PRIVACY_ENABLED &&
|
|
123
133
|
environment.HELYX_PRIVACY_EXECUTOR_DATABASE_URL &&
|
|
124
134
|
environment.HELYX_PRIVACY_EXECUTOR_DATABASE_SSL
|
|
@@ -153,11 +163,12 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
153
163
|
verifiedMigrationCount: migrationResult.verified.length,
|
|
154
164
|
}, "Core database ready");
|
|
155
165
|
const cleanupExpiredCoreRecords = async () => {
|
|
156
|
-
const [sessions, rateLimits, verificationAudit, commandRecords] = await Promise.all([
|
|
166
|
+
const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
|
|
157
167
|
new InteractionSessionRepository(database).deleteExpired(),
|
|
158
168
|
new RateLimitRepository(database).deleteExpired(),
|
|
159
169
|
new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
|
|
160
170
|
new GuildCommandRepository(database).deleteExpired(12),
|
|
171
|
+
new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
|
|
161
172
|
]);
|
|
162
173
|
const commandRecordCount = commandRecords.history +
|
|
163
174
|
commandRecords.idempotencyKeys +
|
|
@@ -166,13 +177,15 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
166
177
|
if (sessions > 0 ||
|
|
167
178
|
rateLimits > 0 ||
|
|
168
179
|
verificationAudit > 0 ||
|
|
169
|
-
commandRecordCount > 0
|
|
180
|
+
commandRecordCount > 0 ||
|
|
181
|
+
hostedLeaveDeliveries > 0) {
|
|
170
182
|
bootstrapLogger.info({
|
|
171
183
|
event: "database.retention_cleaned",
|
|
172
184
|
interactionSessions: sessions,
|
|
173
185
|
rateLimitWindows: rateLimits,
|
|
174
186
|
verificationAuditEvents: verificationAudit,
|
|
175
187
|
commandRecords: commandRecordCount,
|
|
188
|
+
hostedLeaveDeliveries,
|
|
176
189
|
}, "Expired core records removed");
|
|
177
190
|
}
|
|
178
191
|
};
|
|
@@ -339,6 +352,7 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
339
352
|
health: {
|
|
340
353
|
getHealth: buildHealth,
|
|
341
354
|
},
|
|
355
|
+
...(developerModeration ? { moderation: developerModeration } : {}),
|
|
342
356
|
})
|
|
343
357
|
: undefined;
|
|
344
358
|
const modules = [
|
|
@@ -402,7 +416,10 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
402
416
|
...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
|
|
403
417
|
...(operationsModule ? [operationsModule.manifest.id] : []),
|
|
404
418
|
]);
|
|
405
|
-
const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID);
|
|
419
|
+
const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate);
|
|
420
|
+
const hostedLeaveWorker = hostedSafety
|
|
421
|
+
? new HostedServerLeaveWorker(database, client, bootstrapLogger)
|
|
422
|
+
: undefined;
|
|
406
423
|
const commandsReady = installInteractions(client, modules, {
|
|
407
424
|
globalCommandMode: environment.HELYX_GLOBAL_COMMAND_MODE,
|
|
408
425
|
...(environment.DISCORD_INTERNAL_GUILD_ID
|
|
@@ -449,6 +466,7 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
449
466
|
return;
|
|
450
467
|
shuttingDown = true;
|
|
451
468
|
prepareGuilds.stop();
|
|
469
|
+
await hostedLeaveWorker?.stop();
|
|
452
470
|
process.off("SIGINT", onSigint);
|
|
453
471
|
process.off("SIGTERM", onSigterm);
|
|
454
472
|
if (retentionCleanupTimer)
|
|
@@ -513,6 +531,7 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
513
531
|
await runtime.start();
|
|
514
532
|
await client.login(environment.DISCORD_TOKEN);
|
|
515
533
|
await commandsReady;
|
|
534
|
+
hostedLeaveWorker?.start();
|
|
516
535
|
scheduledTasks = createScheduledTaskRunner({
|
|
517
536
|
database,
|
|
518
537
|
modules: publicModules,
|
|
@@ -479,6 +479,8 @@ function successResponse(action, slot) {
|
|
|
479
479
|
return message(`## Premium activated\nSlot ${String(slot.ordinal)} now supplies Premium to this server.`, GREEN);
|
|
480
480
|
}
|
|
481
481
|
function safeFailure(code) {
|
|
482
|
+
if (code === "target_blacklisted")
|
|
483
|
+
return message("Premium cannot be assigned to this server. No slot changes were made.", RED);
|
|
482
484
|
if (code === "target_admin_override")
|
|
483
485
|
return message("This server is already covered by an Admin override. No paid slot was used.", GREEN);
|
|
484
486
|
if (code === "target_already_paid")
|
package/dist/shard-supervisor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
2
|
-
import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
|
|
2
|
+
import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
|
|
3
3
|
import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
|
|
4
4
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
5
5
|
import { Client, ShardingManager } from "discord.js";
|
|
@@ -1697,11 +1697,12 @@ function failedCloud(requestId, message) {
|
|
|
1697
1697
|
}
|
|
1698
1698
|
async function cleanupExpiredCoreRecords(database, logger) {
|
|
1699
1699
|
try {
|
|
1700
|
-
const [sessions, rateLimits, verificationAudit, commandRecords] = await Promise.all([
|
|
1700
|
+
const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
|
|
1701
1701
|
new InteractionSessionRepository(database).deleteExpired(),
|
|
1702
1702
|
new RateLimitRepository(database).deleteExpired(),
|
|
1703
1703
|
new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
|
|
1704
1704
|
new GuildCommandRepository(database).deleteExpired(12),
|
|
1705
|
+
new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
|
|
1705
1706
|
]);
|
|
1706
1707
|
const commandRecordCount = commandRecords.history +
|
|
1707
1708
|
commandRecords.idempotencyKeys +
|
|
@@ -1710,13 +1711,15 @@ async function cleanupExpiredCoreRecords(database, logger) {
|
|
|
1710
1711
|
if (sessions > 0 ||
|
|
1711
1712
|
rateLimits > 0 ||
|
|
1712
1713
|
verificationAudit > 0 ||
|
|
1713
|
-
commandRecordCount > 0
|
|
1714
|
+
commandRecordCount > 0 ||
|
|
1715
|
+
hostedLeaveDeliveries > 0)
|
|
1714
1716
|
logger.info({
|
|
1715
1717
|
event: "database.retention_cleaned",
|
|
1716
1718
|
interactionSessions: sessions,
|
|
1717
1719
|
rateLimitWindows: rateLimits,
|
|
1718
1720
|
verificationAuditEvents: verificationAudit,
|
|
1719
1721
|
commandRecords: commandRecordCount,
|
|
1722
|
+
hostedLeaveDeliveries,
|
|
1720
1723
|
}, "Expired core records removed");
|
|
1721
1724
|
}
|
|
1722
1725
|
catch (error) {
|
package/dist/shard-worker.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
|
|
|
35
35
|
HELYX_MODULE_PACKAGES: string[];
|
|
36
36
|
HELYX_MODULE_CATALOGUE_PATH: string | undefined;
|
|
37
37
|
HELYX_DASHBOARD_PUBLIC_URL: string;
|
|
38
|
+
HELYX_HOSTED_MODERATION_ENABLED: boolean;
|
|
38
39
|
HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
|
|
39
40
|
HELYX_VERIFICATION_ENABLED: boolean;
|
|
40
41
|
HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS: Record<string, string>;
|
package/dist/shard-worker.js
CHANGED
|
@@ -32,6 +32,7 @@ import { PostgresTriviaDataProvider } from "./trivia-data-provider.js";
|
|
|
32
32
|
import { createDeveloperOperationsModule, WorkerShardHealthProvider, } from "./developer-operations-module.js";
|
|
33
33
|
import { assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, guildIdToShardId, SHARD_LEASE_HEARTBEAT_TIMEOUT_MS, shardIdentitySchema, supervisorMessageSchema, } from "./shard-contracts.js";
|
|
34
34
|
import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
|
|
35
|
+
import { HostedBotInviteGateClient, HostedServerLeaveWorker, HostedServerModerationClient, PostgresHostedGuildSafety, } from "./hosted-server-moderation.js";
|
|
35
36
|
import { createSupportModule } from "./support-module.js";
|
|
36
37
|
import { createOAuthModule } from "./oauth-module.js";
|
|
37
38
|
import { createPrivacyModule } from "./privacy-module.js";
|
|
@@ -79,6 +80,15 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
79
80
|
? {}
|
|
80
81
|
: { maxConnections: environment.HELYX_SHARD_WORKER_DATABASE_POOL_MAX }),
|
|
81
82
|
});
|
|
83
|
+
const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
84
|
+
? new PostgresHostedGuildSafety(database)
|
|
85
|
+
: undefined;
|
|
86
|
+
const developerModeration = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
87
|
+
? new HostedServerModerationClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
|
|
88
|
+
: undefined;
|
|
89
|
+
const hostedInviteGate = environment.HELYX_HOSTED_MODERATION_ENABLED
|
|
90
|
+
? new HostedBotInviteGateClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
|
|
91
|
+
: undefined;
|
|
82
92
|
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
83
93
|
eventLoopDelay.enable();
|
|
84
94
|
let status = "starting";
|
|
@@ -90,6 +100,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
90
100
|
let client;
|
|
91
101
|
let cloudProxy;
|
|
92
102
|
let shardHealthProvider;
|
|
103
|
+
let hostedLeaveWorker;
|
|
93
104
|
let leaseHeartbeatWatchdog;
|
|
94
105
|
let forcedLeaseExit;
|
|
95
106
|
let numericWriteBehind;
|
|
@@ -99,6 +110,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
99
110
|
shuttingDown = true;
|
|
100
111
|
status = "stopped";
|
|
101
112
|
guildTracking?.stop();
|
|
113
|
+
await hostedLeaveWorker?.stop();
|
|
102
114
|
if (healthTimer)
|
|
103
115
|
clearInterval(healthTimer);
|
|
104
116
|
leaseHeartbeatWatchdog?.stop();
|
|
@@ -192,6 +204,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
192
204
|
processLabel: `shard worker ${identity.shardId}`,
|
|
193
205
|
triviaData: new PostgresTriviaDataProvider(database),
|
|
194
206
|
health: shardHealthProvider,
|
|
207
|
+
...(developerModeration ? { moderation: developerModeration } : {}),
|
|
195
208
|
})
|
|
196
209
|
: undefined;
|
|
197
210
|
const modules = [
|
|
@@ -265,8 +278,14 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
265
278
|
shardId: identity.shardId,
|
|
266
279
|
fencingToken: BigInt(identity.fencingToken),
|
|
267
280
|
};
|
|
268
|
-
const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount, environment.DISCORD_CLIENT_ID);
|
|
281
|
+
const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate);
|
|
269
282
|
guildTracking = prepareGuilds;
|
|
283
|
+
hostedLeaveWorker = hostedSafety
|
|
284
|
+
? new HostedServerLeaveWorker(database, client, logger, {
|
|
285
|
+
shardId: identity.shardId,
|
|
286
|
+
shardCount: identity.shardCount,
|
|
287
|
+
})
|
|
288
|
+
: undefined;
|
|
270
289
|
const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
|
|
271
290
|
...(environment.DISCORD_INTERNAL_GUILD_ID
|
|
272
291
|
? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
|
|
@@ -358,6 +377,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
358
377
|
runtimeStarted = true;
|
|
359
378
|
await client.login(environment.DISCORD_TOKEN);
|
|
360
379
|
await commandsReady;
|
|
380
|
+
hostedLeaveWorker?.start();
|
|
361
381
|
healthTimer = setInterval(() => sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0)), HEALTH_INTERVAL_MS);
|
|
362
382
|
healthTimer.unref();
|
|
363
383
|
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
@@ -65,6 +65,7 @@ HELYX_SHARD_MODE=off
|
|
|
65
65
|
# HELYX_BOT_CONTROL_SECRET=replace-with-a-base64-encoded-32-byte-key
|
|
66
66
|
# HELYX_BOT_CONTROL_PORT=3001
|
|
67
67
|
# HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS=900
|
|
68
|
+
HELYX_HOSTED_MODERATION_ENABLED=false
|
|
68
69
|
|
|
69
70
|
# Guild command writes remain off until explicitly enabled for known guild IDs.
|
|
70
71
|
HELYX_GUILD_COMMAND_WRITES_ENABLED=false
|