@helyx/bot 1.0.0 → 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 CHANGED
@@ -1,5 +1,29 @@
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
+
21
+ ## 1.0.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Report applied global module availability state through the hosted control interface.
26
+
3
27
  ## 1.0.0
4
28
 
5
29
  ### Major 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 and read-only:
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.
@@ -1048,7 +1048,7 @@ export class BotControlService {
1048
1048
  return module;
1049
1049
  }
1050
1050
  listAvailabilityModules() {
1051
- return availabilityCatalogue(this.#availabilityModules, this.#statusModules);
1051
+ return availabilityCatalogue(this.#availabilityModules, this.#statusModules, moduleAvailabilityService(this.#services));
1052
1052
  }
1053
1053
  async #requireModuleAvailable(moduleIdValue) {
1054
1054
  const availability = moduleAvailabilityService(this.#services);
@@ -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.2.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 read-only development operations for Helyx support.",
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
- return Promise.all(input.userIds.map(async (userId) => {
1508
- const member = await fetchExactMember(guild, userId, "member").catch((error) => {
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 ?? null,
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 syncGuild(guild, true).catch((error) => {
142
+ void admitAndSync(guild, true).catch(() => {
63
143
  logger.error({
64
- event: "guild.sync_failed",
144
+ event: "guild.admission_failed",
65
145
  guildId: guild.id,
66
- error: safeError(error),
67
- }, "Guild installation sync failed");
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,
@@ -10,11 +10,19 @@ export interface AvailabilityModuleCatalogue {
10
10
  modules: AvailabilityModuleDefinition[];
11
11
  statusCatalogueDigest?: string;
12
12
  statusModules?: AvailabilityModuleDefinition[];
13
+ runtimeStates?: AvailabilityModuleRuntimeState[];
14
+ }
15
+ export interface AvailabilityModuleRuntimeState {
16
+ moduleId: string;
17
+ globallyDisabled: boolean;
18
+ revision: string;
19
+ generation: string;
20
+ updatedAt: string | null;
13
21
  }
14
22
  export declare function availabilityCatalogueDefinitions(modules: readonly ModuleDefinition[]): AvailabilityModuleDefinition[];
15
23
  export declare function statusCatalogueDefinitions(modules: readonly ModuleDefinition[]): AvailabilityModuleDefinition[];
16
24
  export declare function availabilityCatalogueSnapshot(definitions: readonly AvailabilityModuleDefinition[], statusDefinitions?: readonly AvailabilityModuleDefinition[]): AvailabilityModuleCatalogue;
17
- export declare function availabilityCatalogue(definitions: readonly AvailabilityModuleDefinition[], statusDefinitions?: readonly AvailabilityModuleDefinition[]): Promise<AvailabilityModuleCatalogue>;
25
+ export declare function availabilityCatalogue(definitions: readonly AvailabilityModuleDefinition[], statusDefinitions?: readonly AvailabilityModuleDefinition[], runtime?: ModuleAvailabilityService): Promise<AvailabilityModuleCatalogue>;
18
26
  export declare function moduleAvailabilityService(services: ServiceAccess): ModuleAvailabilityService | undefined;
19
27
  export declare function moduleAvailabilityStates(services: ServiceAccess, moduleIds: readonly string[]): Promise<ModuleAvailabilityState[]>;
20
28
  export declare function dashboardModuleStatus(globallyDisabled: ReadonlySet<string>, moduleId: string, installation: {
@@ -35,8 +35,20 @@ export function availabilityCatalogueSnapshot(definitions, statusDefinitions = d
35
35
  statusModules,
36
36
  };
37
37
  }
38
- export function availabilityCatalogue(definitions, statusDefinitions = definitions) {
39
- return Promise.resolve(availabilityCatalogueSnapshot(definitions, statusDefinitions));
38
+ export function availabilityCatalogue(definitions, statusDefinitions = definitions, runtime) {
39
+ const catalogue = availabilityCatalogueSnapshot(definitions, statusDefinitions);
40
+ if (!runtime)
41
+ return Promise.resolve(catalogue);
42
+ return Promise.all(definitions.map(({ id }) => runtime.get(id))).then((states) => ({
43
+ ...catalogue,
44
+ runtimeStates: states.map((state) => ({
45
+ moduleId: state.moduleId,
46
+ globallyDisabled: state.globallyDisabled,
47
+ revision: state.revision.toString(),
48
+ generation: state.generation.toString(),
49
+ updatedAt: state.updatedAt?.toISOString() ?? null,
50
+ })),
51
+ }));
40
52
  }
41
53
  export function moduleAvailabilityService(services) {
42
54
  if (!services.has(HELYX_SERVICE_NAMES.moduleAvailability))
@@ -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")
@@ -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";
@@ -277,8 +277,9 @@ export async function startShardSupervisor(environment, processEnvironment, work
277
277
  commandModules = modules;
278
278
  const availabilityModules = availabilityCatalogueDefinitions(modules);
279
279
  const statusModules = statusCatalogueDefinitions(featureModules);
280
+ let runtimeAvailability;
280
281
  const availabilityService = {
281
- listAvailabilityModules: () => availabilityCatalogue(availabilityModules, statusModules),
282
+ listAvailabilityModules: () => availabilityCatalogue(availabilityModules, statusModules, runtimeAvailability),
282
283
  };
283
284
  const initialiseLeader = async () => {
284
285
  await runLeaderStartup(database, environment, modulePackages, modules, logger);
@@ -286,6 +287,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
286
287
  privacyDatabase = await createPrivacyDatabase(environment, database);
287
288
  assertStartupLeaseHealthy();
288
289
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
290
+ runtimeAvailability = services.get(HELYX_SERVICE_NAMES.moduleAvailability);
289
291
  if (numericWriteBehind)
290
292
  services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
291
293
  privacyService = new BotControlService(database, [
@@ -1695,11 +1697,12 @@ function failedCloud(requestId, message) {
1695
1697
  }
1696
1698
  async function cleanupExpiredCoreRecords(database, logger) {
1697
1699
  try {
1698
- const [sessions, rateLimits, verificationAudit, commandRecords] = await Promise.all([
1700
+ const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
1699
1701
  new InteractionSessionRepository(database).deleteExpired(),
1700
1702
  new RateLimitRepository(database).deleteExpired(),
1701
1703
  new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
1702
1704
  new GuildCommandRepository(database).deleteExpired(12),
1705
+ new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
1703
1706
  ]);
1704
1707
  const commandRecordCount = commandRecords.history +
1705
1708
  commandRecords.idempotencyKeys +
@@ -1708,13 +1711,15 @@ async function cleanupExpiredCoreRecords(database, logger) {
1708
1711
  if (sessions > 0 ||
1709
1712
  rateLimits > 0 ||
1710
1713
  verificationAudit > 0 ||
1711
- commandRecordCount > 0)
1714
+ commandRecordCount > 0 ||
1715
+ hostedLeaveDeliveries > 0)
1712
1716
  logger.info({
1713
1717
  event: "database.retention_cleaned",
1714
1718
  interactionSessions: sessions,
1715
1719
  rateLimitWindows: rateLimits,
1716
1720
  verificationAuditEvents: verificationAudit,
1717
1721
  commandRecords: commandRecordCount,
1722
+ hostedLeaveDeliveries,
1718
1723
  }, "Expired core records removed");
1719
1724
  }
1720
1725
  catch (error) {
@@ -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>;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -56,7 +56,7 @@
56
56
  "@helyx/database": ">=2.0.0 <3.0.0",
57
57
  "@helyx/framework": ">=0.1.0 <0.2.0",
58
58
  "@helyx/module-manifest": ">=0.9.0 <0.10.0",
59
- "@helyx/platform": ">=1.0.0",
59
+ "@helyx/platform": ">=1.0.0 <2.0.0",
60
60
  "@helyx/sdk": ">=0.11.0 <0.12.0",
61
61
  "@helyx/status-client": ">=0.1.0 <0.2.0",
62
62
  "@helyx/verification-contracts": ">=0.3.0 <0.4.0",