@helyx/bot 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +77 -28
  2. package/README.md +65 -11
  3. package/dist/cloud-connector.d.ts +11 -4
  4. package/dist/cloud-connector.js +52 -15
  5. package/dist/cloud-control-dispatcher.d.ts +8 -3
  6. package/dist/cloud-control-dispatcher.js +62 -4
  7. package/dist/control-errors.d.ts +4 -0
  8. package/dist/control-errors.js +8 -0
  9. package/dist/control-server-schemas.d.ts +84 -0
  10. package/dist/control-server-schemas.js +101 -0
  11. package/dist/control-server.d.ts +21 -7
  12. package/dist/control-server.js +88 -132
  13. package/dist/core-retention.d.ts +26 -0
  14. package/dist/core-retention.js +49 -0
  15. package/dist/creator-guild-join-service.d.ts +25 -0
  16. package/dist/creator-guild-join-service.js +218 -0
  17. package/dist/creator-invite-service.d.ts +37 -0
  18. package/dist/creator-invite-service.js +686 -0
  19. package/dist/creator-onboarding-control.d.ts +12 -0
  20. package/dist/creator-onboarding-control.js +65 -0
  21. package/dist/creator-onboarding-platform.d.ts +20 -0
  22. package/dist/creator-onboarding-platform.js +58 -0
  23. package/dist/dashboard-action-control.d.ts +2 -2
  24. package/dist/dashboard-action-control.js +2 -3
  25. package/dist/database-bootstrap.d.ts +36 -0
  26. package/dist/database-bootstrap.js +101 -0
  27. package/dist/developer-operations-module.d.ts +1 -0
  28. package/dist/developer-operations-module.js +34 -16
  29. package/dist/discord-owned-message-service.d.ts +1 -0
  30. package/dist/discord-owned-message-service.js +3 -0
  31. package/dist/guild-command-read-service.d.ts +8 -3
  32. package/dist/guild-command-read-service.js +14 -7
  33. package/dist/guild-command-release.d.ts +15 -8
  34. package/dist/guild-command-release.js +10 -3
  35. package/dist/guild-command-worker.d.ts +17 -4
  36. package/dist/guild-command-worker.js +17 -6
  37. package/dist/guilds.d.ts +24 -3
  38. package/dist/guilds.js +185 -64
  39. package/dist/hosted-server-moderation.d.ts +2 -2
  40. package/dist/hosted-server-moderation.js +10 -4
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +200 -82
  43. package/dist/interactions.js +16 -3
  44. package/dist/module-configuration.d.ts +1 -1
  45. package/dist/module-configuration.js +5 -2
  46. package/dist/module-logging.d.ts +8 -3
  47. package/dist/module-logging.js +13 -7
  48. package/dist/modules.d.ts +1 -0
  49. package/dist/modules.js +8 -1
  50. package/dist/mysql-runtime-persistence.d.ts +46 -0
  51. package/dist/mysql-runtime-persistence.js +91 -0
  52. package/dist/oauth-module.js +2 -2
  53. package/dist/oauth-service.d.ts +7 -0
  54. package/dist/oauth-service.js +3 -0
  55. package/dist/scheduled-task-runner.d.ts +9 -85
  56. package/dist/scheduled-task-runner.js +1 -1
  57. package/dist/shard-contracts.d.ts +135 -0
  58. package/dist/shard-contracts.js +13 -1
  59. package/dist/shard-router.d.ts +2 -1
  60. package/dist/shard-router.js +7 -0
  61. package/dist/shard-supervisor.js +43 -9
  62. package/dist/shard-worker.d.ts +5 -2
  63. package/dist/shard-worker.js +41 -5
  64. package/dist/supervisor-cloud-directory.d.ts +9 -3
  65. package/dist/supervisor-cloud-directory.js +8 -12
  66. package/dist/trivia-data-provider.d.ts +11 -0
  67. package/dist/trivia-data-provider.js +46 -26
  68. package/dist/verification-service.d.ts +2 -2
  69. package/dist/verification-service.js +6 -3
  70. package/documentation/README.md +5 -3
  71. package/documentation/examples/.env.example +15 -11
  72. package/documentation/guides/getting-started.md +29 -12
  73. package/documentation/guides/privacy-and-support.md +6 -5
  74. package/documentation/guides/self-hosting.md +16 -11
  75. package/documentation/modules/auto-moderation.md +7 -5
  76. package/documentation/modules/building-a-module.md +48 -2
  77. package/documentation/modules/moderation.md +5 -5
  78. package/documentation/modules/publishing-a-module.md +6 -4
  79. package/documentation/modules/testing-a-module.md +39 -4
  80. package/documentation/reference/environment.md +43 -9
  81. package/documentation/runbooks/backup-and-recovery.md +26 -7
  82. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  83. package/package.json +20 -16
@@ -1,5 +1,6 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
- import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
2
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InstallationRepository, inspectConfiguredModuleMigrations, inspectCoreMigrations, inspectCreatorOnboardingMigrations, runCreatorOnboardingMigrations, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
+ import { assertRepositoryEngineSupported } from "@helyx/database/adapters";
3
4
  import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
5
  import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
5
6
  import { Client, ShardingManager } from "discord.js";
@@ -102,6 +103,7 @@ function sameNumericIdentity(left, right) {
102
103
  left.fencingToken === right.fencingToken);
103
104
  }
104
105
  export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
106
+ assertRepositoryEngineSupported(environment.DATABASE_ENGINE, ["postgresql"], "Bot shard supervisor");
105
107
  if (environment.HELYX_SHARD_MODE === "off")
106
108
  throw new Error("Shard supervisor requires sharding to be enabled");
107
109
  const shardMode = environment.HELYX_SHARD_MODE;
@@ -185,7 +187,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
185
187
  maxConcurrency: gateway.session_start_limit.max_concurrency,
186
188
  observedAt: new Date().toISOString(),
187
189
  };
188
- const supervisorDirectory = new SupervisorCloudDiscordDirectory(environment.DISCORD_TOKEN, database, environment.HELYX_OWNER_USER_IDS);
190
+ const supervisorDirectory = new SupervisorCloudDiscordDirectory(environment.DISCORD_TOKEN, new InstallationRepository(database), environment.HELYX_OWNER_USER_IDS);
189
191
  const eckoOperations = new EckoOperationsRepository(database);
190
192
  cloudConnector = new CloudConnectorController({
191
193
  environment,
@@ -255,6 +257,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
255
257
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
256
258
  approvedRoleIds: environment.APPROVED_ROLES,
257
259
  botVersion: botVersion(),
260
+ databaseEngine: environment.DATABASE_ENGINE,
258
261
  ...(processEnvironment.RAILWAY_GIT_COMMIT_SHA
259
262
  ? {
260
263
  deploymentReference: processEnvironment.RAILWAY_GIT_COMMIT_SHA.slice(0, 12),
@@ -282,6 +285,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
282
285
  ...(operationsModule ? [operationsModule] : []),
283
286
  ];
284
287
  commandModules = modules;
288
+ const guildCommandReads = new GuildCommandReadService(database, environment.DISCORD_CLIENT_ID, () => guildCommandWorker?.getHealth(), environment.DISCORD_TOKEN, environment.HELYX_GUILD_COMMAND_WRITES_ENABLED, environment.HELYX_GUILD_COMMAND_WRITE_GUILD_IDS, environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all", commandModules);
285
289
  const availabilityModules = availabilityCatalogueDefinitions(modules);
286
290
  const statusModules = statusCatalogueDefinitions(featureModules);
287
291
  let runtimeAvailability;
@@ -328,7 +332,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
328
332
  notificationGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
329
333
  }
330
334
  : {}),
331
- }));
335
+ }), guildCommandReads);
332
336
  await startLeaderSingletons();
333
337
  assertStartupLeaseHealthy();
334
338
  };
@@ -425,7 +429,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
425
429
  : {}),
426
430
  });
427
431
  if (!leaderLease)
428
- cloudConnector.setControlHandler(router);
432
+ cloudConnector.setControlHandler(router, guildCommandReads);
429
433
  const claimedIds = await claimInitialShards();
430
434
  manager = new ShardingManager(workerEntry, {
431
435
  totalShards: shardCount,
@@ -480,7 +484,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
480
484
  },
481
485
  shardHealth: buildHealth,
482
486
  readiness: buildDeploymentReadiness,
483
- guildCommands: new GuildCommandReadService(database, environment.DISCORD_CLIENT_ID, () => guildCommandWorker?.getHealth(), environment.DISCORD_TOKEN, environment.HELYX_GUILD_COMMAND_WRITES_ENABLED, environment.HELYX_GUILD_COMMAND_WRITE_GUILD_IDS, environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all", commandModules),
487
+ guildCommands: guildCommandReads,
484
488
  ...(ecko ? { ecko } : {}),
485
489
  });
486
490
  await controlServer.listen({
@@ -1529,12 +1533,31 @@ export function splitNumericCommittedBatch(batch, index) {
1529
1533
  };
1530
1534
  }
1531
1535
  async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
1532
- await runCoreMigrations(database);
1533
- await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
1536
+ const moduleMigrationSources = modulePackages.map(({ definition, packageRoot }) => ({
1534
1537
  moduleId: definition.manifest.id,
1535
1538
  moduleVersion: definition.manifest.version,
1536
1539
  migrationsDirectory: join(packageRoot, "migrations"),
1537
- })));
1540
+ }));
1541
+ if (environment.HELYX_RUNTIME_PROFILE === "hosted" &&
1542
+ modulePackages.some(({ definition }) => definition.manifest.id === "helyx.creator-connections")) {
1543
+ if (environment.HELYX_DATABASE_STARTUP_MODE === "inspect") {
1544
+ const inspection = await inspectCreatorOnboardingMigrations(database);
1545
+ assertNoPendingLeaderMigrations("hosted creator onboarding", inspection.pending);
1546
+ }
1547
+ else
1548
+ await runCreatorOnboardingMigrations(database);
1549
+ }
1550
+ if (environment.HELYX_DATABASE_STARTUP_MODE === "inspect") {
1551
+ const coreInspection = await inspectCoreMigrations(database);
1552
+ assertNoPendingLeaderMigrations("core", coreInspection.pending);
1553
+ const moduleInspections = await inspectConfiguredModuleMigrations(database, moduleMigrationSources);
1554
+ for (const inspection of moduleInspections)
1555
+ assertNoPendingLeaderMigrations(`module ${inspection.moduleId}`, inspection.pending);
1556
+ }
1557
+ else {
1558
+ await runCoreMigrations(database);
1559
+ await runConfiguredModuleMigrations(database, moduleMigrationSources);
1560
+ }
1538
1561
  await enforcePremiumCatalogue(database, logger);
1539
1562
  const repository = new RuntimeStateRepository(database);
1540
1563
  await synchroniseApplicationCommands({
@@ -1545,7 +1568,8 @@ async function runLeaderStartup(database, environment, modulePackages, modules,
1545
1568
  ...(environment.DISCORD_INTERNAL_GUILD_ID
1546
1569
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
1547
1570
  : {}),
1548
- synchroniseInternalGuild: !environment.HELYX_GUILD_COMMAND_WRITES_ENABLED,
1571
+ // Worker-owned guild collections stay untouched while command writes are off.
1572
+ synchroniseInternalGuild: false,
1549
1573
  state: new RuntimeCommandHashStore(repository),
1550
1574
  });
1551
1575
  await setRuntimeState(repository, `shards:${environment.HELYX_SHARD_GENERATION ?? "local"}:startup`, {
@@ -1554,6 +1578,10 @@ async function runLeaderStartup(database, environment, modulePackages, modules,
1554
1578
  });
1555
1579
  logger.info({ event: "shard.singleton_startup_complete" }, "Shard singleton startup complete");
1556
1580
  }
1581
+ function assertNoPendingLeaderMigrations(scope, pending) {
1582
+ if (pending.length > 0)
1583
+ throw new Error(`Hosted ${scope} migrations must be applied by the dedicated primary migration identity before bot startup: ${pending.join(", ")}`);
1584
+ }
1557
1585
  class RuntimeCommandHashStore {
1558
1586
  repository;
1559
1587
  constructor(repository) {
@@ -1635,6 +1663,7 @@ async function fetchGatewayBotInformation(token) {
1635
1663
  }
1636
1664
  export function buildShardWorkerEnvironment(source, identity, restartCount) {
1637
1665
  const allowed = new Set([
1666
+ "DATABASE_ENGINE",
1638
1667
  "DATABASE_URL",
1639
1668
  "DATABASE_SSL",
1640
1669
  "DISCORD_TOKEN",
@@ -1680,8 +1709,13 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1680
1709
  const selectionKey = source.HELYX_DATA_ENCRYPTION_KEY
1681
1710
  ? Buffer.from(deriveSecureRandomSelectionKey(source.HELYX_DATA_ENCRYPTION_KEY)).toString("base64")
1682
1711
  : undefined;
1712
+ const hostedModerationCredential = source.HELYX_HOSTED_MODERATION_ENABLED === "true" &&
1713
+ source.HELYX_BOT_CONTROL_SECRET
1714
+ ? { HELYX_BOT_CONTROL_SECRET: source.HELYX_BOT_CONTROL_SECRET }
1715
+ : {};
1683
1716
  return {
1684
1717
  ...environment,
1718
+ ...hostedModerationCredential,
1685
1719
  ...(selectionKey
1686
1720
  ? { [secureRandomSelectionWorkerKeyName]: selectionKey }
1687
1721
  : {}),
@@ -28,8 +28,6 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
28
28
  HELYX_SHARD_WORKERS_PER_REPLICA: number;
29
29
  HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: number | undefined;
30
30
  HELYX_SHARD_WORKER_DATABASE_POOL_MAX: number | undefined;
31
- DATABASE_URL: string;
32
- DATABASE_SSL: "disable" | "require" | "verify-full";
33
31
  HELYX_RUNTIME_PROFILE: "hosted" | "self-hosted";
34
32
  DISCORD_TOKEN: string;
35
33
  DISCORD_CLIENT_ID: string;
@@ -70,6 +68,10 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
70
68
  HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: number;
71
69
  LOG_LEVEL: "error" | "fatal" | "warn" | "info" | "debug" | "trace" | "silent";
72
70
  NODE_ENV: "development" | "test" | "production";
71
+ DATABASE_ENGINE: "postgresql" | "mysql";
72
+ DATABASE_URL: string;
73
+ DATABASE_SSL: "disable" | "require" | "verify-full";
74
+ HELYX_DATABASE_STARTUP_MODE: "apply" | "inspect";
73
75
  HELYX_STATUS_ENABLED: boolean;
74
76
  HELYX_STATUS_URL: string;
75
77
  HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS: number;
@@ -80,6 +82,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
80
82
  HELYX_SUPPORT_SERVICE_SECRET?: string | undefined;
81
83
  HELYX_VERIFICATION_INSTALLATION_ID?: string | undefined;
82
84
  HELYX_VERIFICATION_URL?: string | undefined;
85
+ HELYX_VERIFICATION_PUBLIC_URL?: string | undefined;
83
86
  HELYX_VERIFICATION_INTERNAL_SECRET?: string | undefined;
84
87
  HELYX_GUILD_COMMAND_RELEASE_ID?: string | undefined;
85
88
  HELYX_PRIVACY_EXECUTOR_DATABASE_URL?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
3
  import { checkDatabaseConnection, createDatabasePool, PremiumAllocationRepository, } from "@helyx/database";
4
+ import { assertRepositoryEngineSupported } from "@helyx/database/adapters";
4
5
  import { REVIEWED_PREMIUM_MODULE_IDS } from "@helyx/module-manifest";
5
6
  import { createRuntime } from "@helyx/framework";
6
7
  import { createPlatformServices, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
@@ -13,6 +14,7 @@ import { BotControlService, BotControlTransportError, DiscordGuildResourceDirect
13
14
  import { HttpVerificationService } from "./verification-service.js";
14
15
  import { DiscordMessageDeliveryService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
15
16
  import { DiscordModerationMemberEnforcementAdapter } from "./discord-member-enforcement-service.js";
17
+ import { installCreatorInvitePlatform } from "./creator-onboarding-platform.js";
16
18
  import { installGuildTracking, } from "./guilds.js";
17
19
  import { installInteractions, } from "./interactions.js";
18
20
  import { createManagementModule } from "./management-module.js";
@@ -64,6 +66,7 @@ export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_
64
66
  }
65
67
  export async function startShardWorker(processEnvironment = process.env, identity = parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT)) {
66
68
  const environment = parseShardWorkerEnvironment(processEnvironment, identity);
69
+ assertRepositoryEngineSupported(environment.DATABASE_ENGINE, ["postgresql"], "Bot shard worker");
67
70
  const logger = pino({
68
71
  level: environment.LOG_LEVEL,
69
72
  base: {
@@ -109,14 +112,16 @@ export async function startShardWorker(processEnvironment = process.env, identit
109
112
  let hostedLeaveWorker;
110
113
  let leaseHeartbeatWatchdog;
111
114
  let forcedLeaseExit;
115
+ let creatorInvitePlatform;
112
116
  let numericWriteBehind;
113
117
  async function shutdown(reason = "programmatic") {
114
118
  if (shuttingDown)
115
119
  return;
116
120
  shuttingDown = true;
117
121
  status = "stopped";
118
- guildTracking?.stop();
122
+ await guildTracking?.stop();
119
123
  await hostedLeaveWorker?.stop();
124
+ await creatorInvitePlatform?.stop();
120
125
  if (healthTimer)
121
126
  clearInterval(healthTimer);
122
127
  leaseHeartbeatWatchdog?.stop();
@@ -193,6 +198,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
193
198
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
194
199
  approvedRoleIds: environment.APPROVED_ROLES,
195
200
  botVersion: readBotVersion(),
201
+ databaseEngine: environment.DATABASE_ENGINE,
196
202
  ...(processEnvironment.RAILWAY_GIT_COMMIT_SHA
197
203
  ? {
198
204
  deploymentReference: processEnvironment.RAILWAY_GIT_COMMIT_SHA.slice(0, 12),
@@ -232,6 +238,15 @@ export async function startShardWorker(processEnvironment = process.env, identit
232
238
  })) {
233
239
  platformServices.set(name, service);
234
240
  }
241
+ const applicationUrls = serviceAccess.get(HELYX_SERVICE_NAMES.applicationUrls);
242
+ platformServices.set(HELYX_SERVICE_NAMES.applicationUrls, {
243
+ dashboardBaseUrl: applicationUrls.dashboardBaseUrl,
244
+ get dashboardInstallationId() {
245
+ return environment.HELYX_RUNTIME_PROFILE === "self-hosted"
246
+ ? cloudProxy?.status().installationId
247
+ : undefined;
248
+ },
249
+ });
235
250
  await serviceAccess
236
251
  .get(HELYX_SERVICE_NAMES.moduleAvailability)
237
252
  .refresh();
@@ -262,6 +277,25 @@ export async function startShardWorker(processEnvironment = process.env, identit
262
277
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
263
278
  platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
264
279
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
280
+ if (environment.HELYX_RUNTIME_PROFILE === "hosted")
281
+ creatorInvitePlatform = await installCreatorInvitePlatform({
282
+ client,
283
+ database,
284
+ services: serviceAccess,
285
+ modules: publicModules,
286
+ encryptionKey: environment.HELYX_DATA_ENCRYPTION_KEY,
287
+ ...(environment.HELYX_CLOUD_API_URL &&
288
+ environment.HELYX_OAUTH_INTERNAL_SECRET
289
+ ? {
290
+ oauthBroker: {
291
+ baseUrl: environment.HELYX_CLOUD_API_URL,
292
+ secret: environment.HELYX_OAUTH_INTERNAL_SECRET,
293
+ },
294
+ }
295
+ : {}),
296
+ register: (name, service) => platformServices.set(name, service),
297
+ warn: (event) => logger.warn({ event }, "Creator invite cleanup will retry"),
298
+ });
265
299
  const moderationPlatformServices = await installModerationPlatformServices({
266
300
  services: platformServices,
267
301
  serviceAccess,
@@ -310,10 +344,12 @@ export async function startShardWorker(processEnvironment = process.env, identit
310
344
  };
311
345
  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, hostedOwnerObserver);
312
346
  guildTracking = prepareGuilds;
313
- hostedLeaveWorker = new HostedServerLeaveWorker(database, client, logger, {
314
- shardId: identity.shardId,
315
- shardCount: identity.shardCount,
316
- });
347
+ hostedLeaveWorker = environment.HELYX_HOSTED_MODERATION_ENABLED
348
+ ? new HostedServerLeaveWorker(database, client, logger, {
349
+ shardId: identity.shardId,
350
+ shardCount: identity.shardCount,
351
+ })
352
+ : undefined;
317
353
  const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
318
354
  ...(environment.DISCORD_INTERNAL_GUILD_ID
319
355
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -1,11 +1,17 @@
1
- import type { DatabasePool } from "@helyx/database";
2
1
  import type { CloudDiscordDirectory, CloudDiscordSnapshot } from "./cloud-connector.js";
3
2
  export declare const CLOUD_HELLO_GUILD_LIMIT = 1000;
3
+ export interface SupervisorInstallationDirectory {
4
+ listActiveGuilds(): Promise<readonly {
5
+ guildId: string;
6
+ name: string | null;
7
+ ownerUserId: string | null;
8
+ }[]>;
9
+ }
4
10
  export declare class SupervisorCloudDiscordDirectory implements CloudDiscordDirectory {
5
11
  #private;
6
- private readonly database;
12
+ private readonly installations;
7
13
  private readonly configuredOwnerUserIds;
8
- constructor(token: string, database: DatabasePool, configuredOwnerUserIds: readonly string[]);
14
+ constructor(token: string, installations: SupervisorInstallationDirectory, configuredOwnerUserIds: readonly string[]);
9
15
  snapshot(): Promise<CloudDiscordSnapshot>;
10
16
  }
11
17
  //# sourceMappingURL=supervisor-cloud-directory.d.ts.map
@@ -25,23 +25,19 @@ const userSchema = z
25
25
  .object({ id: snowflake, username: z.string().min(1).max(100) })
26
26
  .passthrough();
27
27
  export class SupervisorCloudDiscordDirectory {
28
- database;
28
+ installations;
29
29
  configuredOwnerUserIds;
30
30
  #rest;
31
- constructor(token, database, configuredOwnerUserIds) {
32
- this.database = database;
31
+ constructor(token, installations, configuredOwnerUserIds) {
32
+ this.installations = installations;
33
33
  this.configuredOwnerUserIds = configuredOwnerUserIds;
34
34
  this.#rest = new REST({ version: "10" }).setToken(token);
35
35
  }
36
36
  async snapshot() {
37
- const [applicationValue, botValue, guildResult] = await Promise.all([
37
+ const [applicationValue, botValue, activeGuilds] = await Promise.all([
38
38
  this.#rest.get(Routes.oauth2CurrentApplication()),
39
39
  this.#rest.get(Routes.user("@me")),
40
- this.database.query(`SELECT guild_id, name, owner_user_id
41
- FROM discord_guilds
42
- WHERE active = true
43
- ORDER BY guild_id
44
- LIMIT ${String(CLOUD_HELLO_GUILD_LIMIT)}`),
40
+ this.installations.listActiveGuilds(),
45
41
  ]);
46
42
  const application = applicationSchema.parse(applicationValue);
47
43
  const bot = userSchema.parse(botValue);
@@ -55,10 +51,10 @@ export class SupervisorCloudDiscordDirectory {
55
51
  ownerUserIds: [
56
52
  ...new Set([...this.configuredOwnerUserIds, ...applicationOwnerIds]),
57
53
  ],
58
- guilds: guildResult.rows.map((guild) => ({
59
- guildId: guild.guild_id,
54
+ guilds: activeGuilds.slice(0, CLOUD_HELLO_GUILD_LIMIT).map((guild) => ({
55
+ guildId: guild.guildId,
60
56
  name: guild.name ?? "Unknown server",
61
- ownerUserId: guild.owner_user_id,
57
+ ownerUserId: guild.ownerUserId,
62
58
  })),
63
59
  };
64
60
  }
@@ -18,9 +18,20 @@ export interface TriviaDataSummary {
18
18
  export interface TriviaDataProvider {
19
19
  getSummary(): Promise<TriviaDataSummary>;
20
20
  }
21
+ interface MysqlTriviaExecutor {
22
+ query<Row extends Record<string, unknown>>(sql: string): Promise<{
23
+ rows: readonly Row[];
24
+ }>;
25
+ }
21
26
  export declare class PostgresTriviaDataProvider implements TriviaDataProvider {
22
27
  #private;
23
28
  constructor(database: DatabasePool);
24
29
  getSummary(): Promise<TriviaDataSummary>;
25
30
  }
31
+ export declare class MysqlTriviaDataProvider implements TriviaDataProvider {
32
+ #private;
33
+ constructor(database: MysqlTriviaExecutor);
34
+ getSummary(): Promise<TriviaDataSummary>;
35
+ }
36
+ export {};
26
37
  //# sourceMappingURL=trivia-data-provider.d.ts.map
@@ -14,34 +14,54 @@ export class PostgresTriviaDataProvider {
14
14
  GROUP BY category, difficulty, type
15
15
  ORDER BY category, difficulty, type`),
16
16
  ]);
17
- const counts = new Map();
18
- for (const row of countResult.rows) {
19
- if (!TRIVIA_CATEGORIES.includes(row.category) ||
20
- !difficulties.includes(row.difficulty) ||
21
- !["text_choice", "text_input", "true_false"].includes(row.type))
22
- throw new Error("Trivia data contains an unsupported group");
23
- counts.set(`${row.category}:${row.difficulty}`, (counts.get(`${row.category}:${row.difficulty}`) ?? 0) +
24
- nonNegativeInteger(row.questions, "Trivia question count"));
25
- }
26
- const typeCount = (type) => countResult.rows
27
- .filter((row) => row.type === type)
28
- .reduce((total, row) => total + nonNegativeInteger(row.questions, "Trivia type count"), 0);
29
- return {
30
- uniqueQuestions: nonNegativeInteger(uniqueResult.rows[0]?.unique_questions, "Unique trivia question count"),
31
- types: {
32
- multipleChoice: typeCount("text_choice"),
33
- guess: typeCount("text_input"),
34
- trueFalse: typeCount("true_false"),
35
- },
36
- categories: TRIVIA_CATEGORIES.map((category) => ({
37
- category,
38
- easy: counts.get(`${category}:easy`) ?? 0,
39
- medium: counts.get(`${category}:medium`) ?? 0,
40
- hard: counts.get(`${category}:hard`) ?? 0,
41
- })),
42
- };
17
+ return summarizeTriviaData(uniqueResult.rows[0]?.unique_questions, countResult.rows);
43
18
  }
44
19
  }
20
+ export class MysqlTriviaDataProvider {
21
+ #database;
22
+ constructor(database) {
23
+ this.#database = database;
24
+ }
25
+ async getSummary() {
26
+ const [uniqueResult, countResult] = await Promise.all([
27
+ this.#database.query(`SELECT count(DISTINCT lower(trim(JSON_UNQUOTE(JSON_EXTRACT(question, '$.text'))))) AS unique_questions
28
+ FROM trivia_questions`),
29
+ this.#database.query(`SELECT category, difficulty, type, count(*) AS questions
30
+ FROM trivia_questions
31
+ GROUP BY category, difficulty, type
32
+ ORDER BY category, difficulty, type`),
33
+ ]);
34
+ return summarizeTriviaData(uniqueResult.rows[0]?.unique_questions, countResult.rows);
35
+ }
36
+ }
37
+ function summarizeTriviaData(uniqueQuestions, groupedRows) {
38
+ const counts = new Map();
39
+ for (const row of groupedRows) {
40
+ if (!TRIVIA_CATEGORIES.includes(row.category) ||
41
+ !difficulties.includes(row.difficulty) ||
42
+ !["text_choice", "text_input", "true_false"].includes(row.type))
43
+ throw new Error("Trivia data contains an unsupported group");
44
+ counts.set(`${row.category}:${row.difficulty}`, (counts.get(`${row.category}:${row.difficulty}`) ?? 0) +
45
+ nonNegativeInteger(row.questions, "Trivia question count"));
46
+ }
47
+ const typeCount = (type) => groupedRows
48
+ .filter((row) => row.type === type)
49
+ .reduce((total, row) => total + nonNegativeInteger(row.questions, "Trivia type count"), 0);
50
+ return {
51
+ uniqueQuestions: nonNegativeInteger(uniqueQuestions, "Unique trivia question count"),
52
+ types: {
53
+ multipleChoice: typeCount("text_choice"),
54
+ guess: typeCount("text_input"),
55
+ trueFalse: typeCount("true_false"),
56
+ },
57
+ categories: TRIVIA_CATEGORIES.map((category) => ({
58
+ category,
59
+ easy: counts.get(`${category}:easy`) ?? 0,
60
+ medium: counts.get(`${category}:medium`) ?? 0,
61
+ hard: counts.get(`${category}:hard`) ?? 0,
62
+ })),
63
+ };
64
+ }
45
65
  function nonNegativeInteger(value, label) {
46
66
  const parsed = typeof value === "string" ? Number(value) : value;
47
67
  if (typeof parsed !== "number" || !Number.isSafeInteger(parsed) || parsed < 0)
@@ -1,6 +1,6 @@
1
- import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims } from "@helyx/verification-contracts";
1
+ import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims, type VerificationResponseUrlOptions } from "@helyx/verification-contracts";
2
2
  import type { VerificationResultContext, VerificationService } from "@helyx/sdk";
3
- export interface HttpVerificationServiceOptions {
3
+ export interface HttpVerificationServiceOptions extends VerificationResponseUrlOptions {
4
4
  baseUrl: string;
5
5
  internalSecret: string;
6
6
  publicSigningKeys: Readonly<Record<string, string>>;
@@ -1,4 +1,4 @@
1
- import { actionReceiptSchema, challengeCreateRequestSchema, challengeCreateResponseSchema, dashboardAttemptPageSchema, dashboardAttemptQuerySchema, dashboardSafeAttemptDetailSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
1
+ import { actionReceiptSchema, challengeCreateRequestSchema, createChallengeCreateResponseSchema, dashboardAttemptPageSchema, dashboardAttemptQuerySchema, dashboardSafeAttemptDetailSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
2
2
  import { VerificationChallengeActiveError } from "@helyx/sdk";
3
3
  import { createPublicKey, verify } from "node:crypto";
4
4
  import { z } from "zod";
@@ -21,6 +21,7 @@ export class HttpVerificationService {
21
21
  #publicSigningKeys;
22
22
  #fetch;
23
23
  #installationId;
24
+ #challengeResponseSchema;
24
25
  #challengeCache = new Map();
25
26
  #handler;
26
27
  constructor(options) {
@@ -29,6 +30,8 @@ export class HttpVerificationService {
29
30
  this.#publicSigningKeys = { ...options.publicSigningKeys };
30
31
  this.#installationId = options.installationId;
31
32
  this.#fetch = options.fetchImplementation ?? fetch;
33
+ this.#challengeResponseSchema =
34
+ createChallengeCreateResponseSchema(options);
32
35
  }
33
36
  getInstallationContext() {
34
37
  return Promise.resolve({
@@ -54,9 +57,9 @@ export class HttpVerificationService {
54
57
  if (input.replaceActive !== true &&
55
58
  cached &&
56
59
  Date.parse(cached.expiresAt) > Date.now()) {
57
- return challengeCreateResponseSchema.parse({ ...cached, reused: true });
60
+ return this.#challengeResponseSchema.parse({ ...cached, reused: true });
58
61
  }
59
- const created = challengeCreateResponseSchema.parse(await this.#request("/internal/v1/challenges", {
62
+ const created = this.#challengeResponseSchema.parse(await this.#request("/internal/v1/challenges", {
60
63
  method: "POST",
61
64
  body: challengeCreateRequestSchema.parse(input),
62
65
  }));
@@ -23,9 +23,11 @@ and module guides.
23
23
  - [Operate the Moderation module](modules/moderation.md)
24
24
  - [Operate the Auto Moderation module](modules/auto-moderation.md)
25
25
 
26
- The complete working example is published separately as
27
- `@helyx/module-example`. It is intentionally independent: module-specific
28
- commands, settings, tests, and lifecycle behaviour remain inside that module.
26
+ The standalone authoring reference is published separately as
27
+ `@helyx/module-example`. Adapt its compatibility manifest and compose the
28
+ required privacy contribution before bot installation, as described in
29
+ [the author guide](modules/building-a-module.md). Module-specific commands,
30
+ settings, tests and lifecycle behaviour remain inside that module.
29
31
 
30
32
  ## Choose the right path
31
33
 
@@ -10,7 +10,8 @@ DISCORD_CLIENT_ID=12345678901234567
10
10
  # requires message content and it is enabled in the Discord developer portal.
11
11
  HELYX_MESSAGE_CONTENT_ENABLED=false
12
12
  # Enhanced Auto Moderation stays inside this bot process. One worker is the
13
- # recommended default; increase only after measuring local CPU and memory.
13
+ # recommended default (range 1-4); increase only after measuring CPU and memory.
14
+ # Hosted scanning uses the separate Helyx Ecko service, not these settings.
14
15
  HELYX_AUTOMOD_EMBEDDED_WORKERS=1
15
16
  # Optional conservative queue overrides (hard ceilings: 5000 items, 64 MiB,
16
17
  # 100 items per server). Defaults are used when these remain commented.
@@ -18,13 +19,19 @@ HELYX_AUTOMOD_EMBEDDED_WORKERS=1
18
19
  # HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES=67108864
19
20
  # HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD=100
20
21
  # HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS=300
21
- # Enable only when member inspection modules are loaded and the Discord Server
22
- # Members Intent is enabled in the Discord developer portal.
22
+ # Enable for loaded member workflows such as Welcome, Access Control or Levels,
23
+ # with Server Members Intent also enabled in the Discord developer portal.
23
24
  HELYX_GUILD_MEMBERS_ENABLED=false
24
25
  # Database
25
26
  # Docker: keep the URL. Direct Node.js/managed hosting: use your provider URL.
27
+ DATABASE_ENGINE=postgresql
26
28
  DATABASE_URL=postgresql://database:5432/helyx
27
29
  DATABASE_SSL=disable
30
+ # Self-host startup applies pending migrations automatically (the default).
31
+ # Hosted-only inspect mode is not a self-host setting; leave this commented.
32
+ # HELYX_DATABASE_STARTUP_MODE=apply
33
+ # Match your provider: disable=no TLS; require=TLS without certificate
34
+ # verification; verify-full=TLS with certificate verification.
28
35
 
29
36
  # Included Docker database — both password values must match.
30
37
  PGUSER=helyx
@@ -51,8 +58,8 @@ LOG_LEVEL=info
51
58
  # SHARDING: OFF BY DEFAULT
52
59
 
53
60
  # `off` is the compatible self-host default. Keep shard-only values commented
54
- # in this mode; an empty value is still rejected as configured. Advanced local
55
- # self-hosting may use `auto` to obtain Discord's recommendation under one
61
+ # in this mode; an empty value is still rejected as configured. MySQL must stay
62
+ # off. Advanced PostgreSQL self-hosting may use `auto` under one
56
63
  # supervisor, or `fixed` with a deliberate total count. Local mode never needs
57
64
  # Redis. Coordinated multi-host self-hosting is outside the first release.
58
65
  HELYX_SHARD_MODE=off
@@ -80,18 +87,15 @@ HELYX_SHARD_MODE=off
80
87
  HELYX_HOSTED_MODERATION_ENABLED=false
81
88
 
82
89
  # Guild command writes remain off until explicitly enabled for known guild IDs.
90
+ # For enabled-module server commands, set writes=true, retain allowlist scope,
91
+ # add comma-separated Discord server IDs below, and use global mode platform-only.
92
+ # See README.md before changing these defaults. Legacy mode still allows globals.
83
93
  HELYX_GUILD_COMMAND_WRITES_ENABLED=false
84
94
  HELYX_GUILD_COMMAND_WRITE_SCOPE=allowlist
85
95
  # HELYX_GUILD_COMMAND_RELEASE_ID=
86
96
  HELYX_GLOBAL_COMMAND_MODE=legacy
87
97
  # HELYX_GUILD_COMMAND_WRITE_GUILD_IDS=
88
98
 
89
- # Privacy tooling ships with self-hosted Helyx but stays disabled until the
90
- # dedicated least-privilege executor connection has been configured.
91
- HELYX_PRIVACY_ENABLED=false
92
- # HELYX_PRIVACY_EXECUTOR_DATABASE_URL=<dedicated-privacy-executor-postgresql-url>
93
- # HELYX_PRIVACY_EXECUTOR_DATABASE_SSL=disable
94
-
95
99
  # Alternative Cloud Connect enrolment — the Discord command is preferred
96
100
  # HELYX_CLOUD_ENROLMENT_TOKEN=replace-with-a-short-lived-enrolment-code
97
101
  # HELYX_CLOUD_ENROLMENT_FILE=./helyx.cloud-enrolment.json