@helyx/bot 1.3.0 → 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 +92 -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 -81
  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 +46 -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
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
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, HostedServerSafetyRepository, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
+ import { checkDatabaseConnection, createDatabasePool, EckoOperationsRepository, inspectConfiguredModuleMigrations, inspectCoreMigrations, inspectCreatorOnboardingMigrations, runCreatorOnboardingMigrations, PremiumAllocationRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
5
5
  import { createRuntime } from "@helyx/framework";
6
+ import { assertRepositoryEngineSupported } from "@helyx/database/adapters";
6
7
  import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
7
8
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
8
9
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
@@ -15,7 +16,7 @@ import { ClientCloudDiscordDirectory, CloudConnectorController, } from "./cloud-
15
16
  import { createCloudManagementModule } from "./cloud-management-module.js";
16
17
  import { installInteractions } from "./interactions.js";
17
18
  import { installGuildTracking } from "./guilds.js";
18
- import { loadConfiguredModulePackages, platformServiceOptions, } from "./modules.js";
19
+ import { assertConfiguredModuleDatabaseCompatibility, loadConfiguredModulePackages, platformServiceOptions, } from "./modules.js";
19
20
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
20
21
  import { createPermissionsModule } from "./permissions-module.js";
21
22
  import { createManagementModule } from "./management-module.js";
@@ -42,12 +43,16 @@ import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-serv
42
43
  import { DiscordGuildHealthInspection } from "./discord-guild-health.js";
43
44
  import { DiscordAnalyticsInspection } from "./discord-analytics.js";
44
45
  import { DiscordInviteInspection } from "./discord-invites.js";
46
+ import { installCreatorInvitePlatform } from "./creator-onboarding-platform.js";
45
47
  import { GuildCommandWorker } from "./guild-command-worker.js";
46
48
  import { GuildCommandDiscordClient } from "./guild-command-discord-client.js";
47
49
  import { GuildCommandReadService } from "./guild-command-read-service.js";
48
50
  import { scheduleGuildCommandRelease } from "./guild-command-release.js";
49
51
  import { CoreResourceAuthorizationService } from "./resource-authorization.js";
50
52
  import { PostgresTriviaDataProvider } from "./trivia-data-provider.js";
53
+ import { MysqlTriviaDataProvider } from "./trivia-data-provider.js";
54
+ import { cleanupExpiredCoreRecords, createPostgresCoreRetentionRepositories, } from "./core-retention.js";
55
+ import { openMysqlBotRuntimePersistence } from "./mysql-runtime-persistence.js";
51
56
  import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
52
57
  import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
53
58
  import { discordRestOptions } from "./discord-rest.js";
@@ -56,6 +61,7 @@ import { assertReviewedPremiumCatalogue, requirePremiumProviderEnvironment, } fr
56
61
  import { enforcePremiumCatalogue, PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS, } from "./premium-catalogue-enforcement.js";
57
62
  import { HostedBotInviteGateClient, HostedServerLeaveWorker, HostedServerModerationClient, PostgresHostedGuildSafety, } from "./hosted-server-moderation.js";
58
63
  export { loadConfiguredModulePackages } from "./modules.js";
64
+ export { assertConfiguredModuleDatabaseCompatibility } from "./modules.js";
59
65
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
60
66
  export * from "./control-server.js";
61
67
  const bootstrapLogger = pino({ base: { component: "bot" } });
@@ -65,6 +71,11 @@ export async function startBot(processEnvironment = process.env) {
65
71
  if (processEnvironment.HELYX_SHARD_WORKER_CONTRACT !== undefined)
66
72
  return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
67
73
  const environment = parseBotEnvironment(processEnvironment);
74
+ assertRepositoryEngineSupported(environment.DATABASE_ENGINE, ["postgresql", "mysql"], "Bot platform");
75
+ if (environment.DATABASE_ENGINE === "mysql" &&
76
+ environment.HELYX_SHARD_MODE !== "off") {
77
+ throw new Error("MySQL bot runtime does not support shard mode");
78
+ }
68
79
  bootstrapLogger.level = environment.LOG_LEVEL;
69
80
  const handle = environment.HELYX_SHARD_MODE !== "off"
70
81
  ? await startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint())
@@ -117,15 +128,49 @@ export async function startBot(processEnvironment = process.env) {
117
128
  export function resolveBotWorkerEntryPoint(entryUrl = import.meta.url) {
118
129
  return fileURLToPath(entryUrl);
119
130
  }
131
+ async function inspectRequiredCoreMigrations(database) {
132
+ const inspection = await inspectCoreMigrations(database);
133
+ assertNoPendingMigrations("core", inspection.pending);
134
+ return { applied: [], verified: inspection.verified };
135
+ }
136
+ async function inspectRequiredModuleMigrations(database, sources) {
137
+ const inspections = await inspectConfiguredModuleMigrations(database, sources);
138
+ for (const inspection of inspections)
139
+ assertNoPendingMigrations(`module ${inspection.moduleId}`, inspection.pending);
140
+ return inspections.map((inspection) => ({
141
+ moduleId: inspection.moduleId,
142
+ applied: [],
143
+ verified: inspection.verified,
144
+ }));
145
+ }
146
+ function assertNoPendingMigrations(scope, pending) {
147
+ if (pending.length > 0)
148
+ throw new Error(`Hosted ${scope} migrations must be applied by the dedicated primary migration identity before bot startup: ${pending.join(", ")}`);
149
+ }
120
150
  async function startUnshardedBot(processEnvironment) {
121
151
  const environment = parseBotEnvironment(processEnvironment);
122
- const database = createDatabasePool({
123
- connectionString: environment.DATABASE_URL,
124
- sslMode: environment.DATABASE_SSL,
125
- applicationName: "helyx-bot",
126
- });
127
- const hostedOwnerObserver = new PostgresHostedGuildSafety(database);
128
- const eckoOperations = new EckoOperationsRepository(database);
152
+ const mysqlModulePackages = environment.DATABASE_ENGINE === "mysql"
153
+ ? await loadConfiguredModulePackages(await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH, environment.HELYX_RUNTIME_PROFILE))
154
+ : undefined;
155
+ const mysql = mysqlModulePackages
156
+ ? await openMysqlBotRuntimePersistence({
157
+ environment,
158
+ modulePackages: mysqlModulePackages,
159
+ })
160
+ : undefined;
161
+ const database = mysql
162
+ ? undefined
163
+ : createDatabasePool({
164
+ connectionString: environment.DATABASE_URL,
165
+ sslMode: environment.DATABASE_SSL,
166
+ applicationName: "helyx-bot",
167
+ });
168
+ const hostedOwnerObserver = database
169
+ ? new PostgresHostedGuildSafety(database)
170
+ : undefined;
171
+ const eckoOperations = database
172
+ ? new EckoOperationsRepository(database)
173
+ : undefined;
129
174
  const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
130
175
  ? hostedOwnerObserver
131
176
  : undefined;
@@ -148,70 +193,77 @@ async function startUnshardedBot(processEnvironment) {
148
193
  let databaseClosed = false;
149
194
  let retentionCleanupTimer;
150
195
  let premiumCatalogueTimer;
196
+ let creatorInvitePlatform;
151
197
  async function closeDatabase() {
152
198
  if (databaseClosed)
153
199
  return;
154
200
  databaseClosed = true;
155
201
  if (privacyDatabase)
156
202
  await privacyDatabase.end();
157
- await database.end();
203
+ if (database)
204
+ await database.end();
205
+ else
206
+ await mysql?.database.close();
158
207
  }
159
208
  try {
160
- await checkDatabaseConnection(database);
209
+ if (database)
210
+ await checkDatabaseConnection(database);
161
211
  if (privacyDatabase) {
162
212
  await checkDatabaseConnection(privacyDatabase);
213
+ if (!database)
214
+ throw new Error("Privacy executor requires PostgreSQL");
163
215
  await assertDistinctDatabaseRoles(database, privacyDatabase);
164
216
  }
165
- const migrationResult = await runCoreMigrations(database);
217
+ const migrationResult = database
218
+ ? environment.HELYX_DATABASE_STARTUP_MODE === "inspect"
219
+ ? await inspectRequiredCoreMigrations(database)
220
+ : await runCoreMigrations(database)
221
+ : mysql.database.migrations.core;
166
222
  bootstrapLogger.info({
167
223
  event: "database.ready",
168
224
  appliedMigrations: migrationResult.applied,
169
225
  verifiedMigrationCount: migrationResult.verified.length,
170
226
  }, "Core database ready");
171
- const cleanupExpiredCoreRecords = async () => {
172
- const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
173
- new InteractionSessionRepository(database).deleteExpired(),
174
- new RateLimitRepository(database).deleteExpired(),
175
- new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
176
- new GuildCommandRepository(database).deleteExpired(12),
177
- new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
178
- ]);
179
- const commandRecordCount = commandRecords.history +
180
- commandRecords.idempotencyKeys +
181
- commandRecords.creationLedger +
182
- commandRecords.requests;
183
- if (sessions > 0 ||
184
- rateLimits > 0 ||
185
- verificationAudit > 0 ||
186
- commandRecordCount > 0 ||
187
- hostedLeaveDeliveries > 0) {
188
- bootstrapLogger.info({
189
- event: "database.retention_cleaned",
190
- interactionSessions: sessions,
191
- rateLimitWindows: rateLimits,
192
- verificationAuditEvents: verificationAudit,
193
- commandRecords: commandRecordCount,
194
- hostedLeaveDeliveries,
195
- }, "Expired core records removed");
227
+ const modulePackages = mysqlModulePackages ??
228
+ (await loadConfiguredModulePackages(await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH, environment.HELYX_RUNTIME_PROFILE)));
229
+ assertConfiguredModuleDatabaseCompatibility(modulePackages, environment.DATABASE_ENGINE);
230
+ const moduleMigrationSources = modulePackages.map(({ definition, packageRoot }) => ({
231
+ moduleId: definition.manifest.id,
232
+ moduleVersion: definition.manifest.version,
233
+ migrationsDirectory: join(packageRoot, "migrations"),
234
+ }));
235
+ if (database &&
236
+ environment.HELYX_RUNTIME_PROFILE === "hosted" &&
237
+ modulePackages.some(({ definition }) => definition.manifest.id === "helyx.creator-connections")) {
238
+ if (environment.HELYX_DATABASE_STARTUP_MODE === "inspect") {
239
+ const inspection = await inspectCreatorOnboardingMigrations(database);
240
+ assertNoPendingMigrations("hosted creator onboarding", inspection.pending);
196
241
  }
242
+ else
243
+ await runCreatorOnboardingMigrations(database);
244
+ }
245
+ const inspectedModuleMigrationResults = database && environment.HELYX_DATABASE_STARTUP_MODE === "inspect"
246
+ ? await inspectRequiredModuleMigrations(database, moduleMigrationSources)
247
+ : undefined;
248
+ const retentionRepositories = mysql?.coreRetention ??
249
+ createPostgresCoreRetentionRepositories(database);
250
+ const runRetentionCleanup = async () => {
251
+ await cleanupExpiredCoreRecords(retentionRepositories, bootstrapLogger);
197
252
  };
198
- await cleanupExpiredCoreRecords().catch((error) => {
253
+ await runRetentionCleanup().catch((error) => {
199
254
  bootstrapLogger.warn({ event: "database.retention_cleanup_failed", error: safeError(error) }, "Expired core record cleanup failed");
200
255
  });
201
- retentionCleanupTimer = setInterval(() => void cleanupExpiredCoreRecords().catch((error) => {
256
+ retentionCleanupTimer = setInterval(() => void runRetentionCleanup().catch((error) => {
202
257
  bootstrapLogger.warn({
203
258
  event: "database.retention_cleanup_failed",
204
259
  error: safeError(error),
205
260
  }, "Expired core record cleanup failed");
206
261
  }), RETENTION_CLEANUP_INTERVAL_MS);
207
262
  retentionCleanupTimer.unref();
208
- const moduleSpecifiers = await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH);
209
- const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
210
- const moduleMigrationResults = await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
211
- moduleId: definition.manifest.id,
212
- moduleVersion: definition.manifest.version,
213
- migrationsDirectory: join(packageRoot, "migrations"),
214
- })));
263
+ const moduleMigrationResults = inspectedModuleMigrationResults ??
264
+ (database
265
+ ? await runConfiguredModuleMigrations(database, moduleMigrationSources)
266
+ : mysql.database.migrations.modules);
215
267
  bootstrapLogger.info({
216
268
  event: "database.modules_ready",
217
269
  modules: moduleMigrationResults.map((result) => ({
@@ -222,14 +274,16 @@ async function startUnshardedBot(processEnvironment) {
222
274
  }, "Module databases ready");
223
275
  const featureModules = modulePackages.map(({ definition }) => definition);
224
276
  assertReviewedPremiumCatalogue(featureModules);
225
- await enforcePremiumCatalogue(database, bootstrapLogger);
226
- premiumCatalogueTimer = setInterval(() => void enforcePremiumCatalogue(database, bootstrapLogger).catch((error) => {
227
- bootstrapLogger.warn({
228
- event: "premium.catalogue_enforcement_failed",
229
- error: safeError(error),
230
- }, "Premium module catalogue enforcement failed");
231
- }), PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS);
232
- premiumCatalogueTimer.unref();
277
+ if (database) {
278
+ await enforcePremiumCatalogue(database, bootstrapLogger);
279
+ premiumCatalogueTimer = setInterval(() => void enforcePremiumCatalogue(database, bootstrapLogger).catch((error) => {
280
+ bootstrapLogger.warn({
281
+ event: "premium.catalogue_enforcement_failed",
282
+ error: safeError(error),
283
+ }, "Premium module catalogue enforcement failed");
284
+ }), PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS);
285
+ premiumCatalogueTimer.unref();
286
+ }
233
287
  const client = new Client({
234
288
  rest: discordRestOptions,
235
289
  intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED, environment.HELYX_GUILD_MEMBERS_ENABLED, environment.HELYX_RUNTIME_PROFILE),
@@ -239,7 +293,9 @@ async function startUnshardedBot(processEnvironment) {
239
293
  const guildResources = new DiscordGuildResourceDirectory(client);
240
294
  const cloudConnector = new CloudConnectorController({
241
295
  environment,
242
- database,
296
+ ...(mysql
297
+ ? { repository: mysql.cloudConnector }
298
+ : { database: database }),
243
299
  discord: new ClientCloudDiscordDirectory(client),
244
300
  modules: modulePackages,
245
301
  coreVersion: readBotVersion(),
@@ -257,7 +313,7 @@ async function startUnshardedBot(processEnvironment) {
257
313
  const supportModule = createSupportModule(cloudConnector);
258
314
  const privacyModule = createPrivacyModule();
259
315
  const premiumEnvironment = requirePremiumProviderEnvironment(featureModules, environment.STRIPE_ENVIRONMENT);
260
- const premiumActivationModule = !premiumEnvironment
316
+ const premiumActivationModule = !premiumEnvironment || !database
261
317
  ? undefined
262
318
  : createPremiumActivationModule({
263
319
  allocations: new PremiumAllocationRepository(database),
@@ -286,7 +342,10 @@ async function startUnshardedBot(processEnvironment) {
286
342
  const buildHealth = async () => {
287
343
  let databaseHealthy = true;
288
344
  try {
289
- await checkDatabaseConnection(database);
345
+ if (database)
346
+ await checkDatabaseConnection(database);
347
+ else
348
+ await mysql.database.healthCheck();
290
349
  }
291
350
  catch {
292
351
  databaseHealthy = false;
@@ -342,6 +401,7 @@ async function startUnshardedBot(processEnvironment) {
342
401
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
343
402
  approvedRoleIds: environment.APPROVED_ROLES,
344
403
  botVersion: readBotVersion(),
404
+ databaseEngine: environment.DATABASE_ENGINE,
345
405
  ...(deployedCommit ? { deploymentReference: deployedCommit } : {}),
346
406
  coreFunctions: [
347
407
  managementModule,
@@ -354,7 +414,9 @@ async function startUnshardedBot(processEnvironment) {
354
414
  ].map(({ manifest }) => manifest),
355
415
  modules: featureModules.map(({ manifest }) => manifest),
356
416
  processLabel: "unsharded bot process",
357
- triviaData: new PostgresTriviaDataProvider(database),
417
+ triviaData: mysql
418
+ ? new MysqlTriviaDataProvider((await import("@helyx/database-mysql")).createMysqlQueryExecutor(mysql.pool))
419
+ : new PostgresTriviaDataProvider(database),
358
420
  health: {
359
421
  getHealth: buildHealth,
360
422
  },
@@ -368,12 +430,29 @@ async function startUnshardedBot(processEnvironment) {
368
430
  const platformServices = new Map();
369
431
  const serviceAccess = new ServiceRegistry(platformServices);
370
432
  const serviceOptions = platformServiceOptions(environment, publicModules);
371
- for (const [name, service] of createPlatformServices(database, publicModules, {
372
- ...serviceOptions,
373
- activationServices: serviceAccess,
374
- })) {
433
+ const platformFactory = mysql
434
+ ? mysql.createPlatformServices(publicModules, {
435
+ ...serviceOptions,
436
+ recordRecoveryOwnership: { mode: "single-worker" },
437
+ activationServices: serviceAccess,
438
+ })
439
+ : createPlatformServices(database, publicModules, {
440
+ ...serviceOptions,
441
+ recordRecoveryOwnership: { mode: "single-worker" },
442
+ activationServices: serviceAccess,
443
+ });
444
+ for (const [name, service] of platformFactory) {
375
445
  platformServices.set(name, service);
376
446
  }
447
+ const applicationUrls = serviceAccess.get(HELYX_SERVICE_NAMES.applicationUrls);
448
+ platformServices.set(HELYX_SERVICE_NAMES.applicationUrls, {
449
+ dashboardBaseUrl: applicationUrls.dashboardBaseUrl,
450
+ get dashboardInstallationId() {
451
+ return environment.HELYX_RUNTIME_PROFILE === "self-hosted"
452
+ ? cloudConnector.status().installationId
453
+ : undefined;
454
+ },
455
+ });
377
456
  await serviceAccess
378
457
  .get(HELYX_SERVICE_NAMES.moduleAvailability)
379
458
  .refresh();
@@ -385,6 +464,10 @@ async function startUnshardedBot(processEnvironment) {
385
464
  internalSecret: environment.HELYX_VERIFICATION_INTERNAL_SECRET,
386
465
  installationId: environment.HELYX_VERIFICATION_INSTALLATION_ID,
387
466
  publicSigningKeys: environment.HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS,
467
+ runtimeEnvironment: environment.NODE_ENV,
468
+ ...(environment.HELYX_VERIFICATION_PUBLIC_URL === undefined
469
+ ? {}
470
+ : { publicOrigin: environment.HELYX_VERIFICATION_PUBLIC_URL }),
388
471
  }));
389
472
  platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
390
473
  }
@@ -399,8 +482,29 @@ async function startUnshardedBot(processEnvironment) {
399
482
  platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordModerationMemberEnforcementAdapter(client));
400
483
  const memberRoles = new DiscordMemberRoleService(client);
401
484
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
402
- platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
485
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(mysql
486
+ ? mysql.memberRoleOwnership
487
+ : new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
403
488
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
489
+ if (database && environment.HELYX_RUNTIME_PROFILE === "hosted")
490
+ creatorInvitePlatform = await installCreatorInvitePlatform({
491
+ client,
492
+ database,
493
+ services: serviceAccess,
494
+ modules: publicModules,
495
+ encryptionKey: environment.HELYX_DATA_ENCRYPTION_KEY,
496
+ ...(environment.HELYX_CLOUD_API_URL &&
497
+ environment.HELYX_OAUTH_INTERNAL_SECRET
498
+ ? {
499
+ oauthBroker: {
500
+ baseUrl: environment.HELYX_CLOUD_API_URL,
501
+ secret: environment.HELYX_OAUTH_INTERNAL_SECRET,
502
+ },
503
+ }
504
+ : {}),
505
+ register: (name, service) => platformServices.set(name, service),
506
+ warn: (event) => bootstrapLogger.warn({ event }, "Creator invite cleanup will retry"),
507
+ });
404
508
  const moderationPlatformServices = await installModerationPlatformServices({
405
509
  services: platformServices,
406
510
  serviceAccess,
@@ -421,7 +525,7 @@ async function startUnshardedBot(processEnvironment) {
421
525
  if (serviceOptions.inviteFingerprintKey)
422
526
  platformServices.set(HELYX_SERVICE_NAMES.discordInvites, new DiscordInviteInspection(client, serviceOptions.inviteFingerprintKey));
423
527
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
424
- platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
528
+ platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, mysql?.logging ?? database, publicModules, bootstrapLogger));
425
529
  }
426
530
  const runtime = createRuntime({
427
531
  modules,
@@ -437,14 +541,18 @@ async function startUnshardedBot(processEnvironment) {
437
541
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
438
542
  ...(operationsModule ? [operationsModule.manifest.id] : []),
439
543
  ]);
440
- const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate, hostedOwnerObserver);
441
- const hostedLeaveWorker = new HostedServerLeaveWorker(database, client, bootstrapLogger);
544
+ const prepareGuilds = installGuildTracking(client, mysql?.guildTracking ?? database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate, hostedOwnerObserver);
545
+ const hostedLeaveWorker = database && environment.HELYX_HOSTED_MODERATION_ENABLED
546
+ ? new HostedServerLeaveWorker(database, client, bootstrapLogger)
547
+ : undefined;
442
548
  const commandsReady = installInteractions(client, modules, {
443
549
  globalCommandMode: environment.HELYX_GLOBAL_COMMAND_MODE,
444
550
  ...(environment.DISCORD_INTERNAL_GUILD_ID
445
551
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
446
552
  : {}),
447
- synchroniseInternalGuild: !environment.HELYX_GUILD_COMMAND_WRITES_ENABLED,
553
+ // The guild-command worker owns the complete collection. A write-disabled
554
+ // startup must not replace existing module commands with internal roots.
555
+ synchroniseInternalGuild: false,
448
556
  prepare: prepareGuilds,
449
557
  selfAuthorizingGuildModuleIds: new Set([
450
558
  supportModule.manifest.id,
@@ -459,7 +567,7 @@ async function startUnshardedBot(processEnvironment) {
459
567
  runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
460
568
  evidenceStore: moderationPlatformServices.evidenceStore,
461
569
  });
462
- const controlService = new BotControlService(database, [
570
+ const controlService = new BotControlService(mysql?.control ?? database, [
463
571
  ...publicModules,
464
572
  gamingStatisticsRecordOwner,
465
573
  achievementContributionRecordOwner,
@@ -480,14 +588,15 @@ async function startUnshardedBot(processEnvironment) {
480
588
  channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
481
589
  }
482
590
  : undefined, undefined, undefined, featureModules);
483
- cloudConnector.setControlHandler(controlService);
591
+ const guildCommandReads = new GuildCommandReadService(mysql?.commandRead ?? 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", modules);
592
+ cloudConnector.setControlHandler(controlService, guildCommandReads);
484
593
  let controlServer;
485
594
  let shuttingDown = false;
486
595
  async function shutdown(reason = "programmatic") {
487
596
  if (shuttingDown)
488
597
  return;
489
598
  shuttingDown = true;
490
- prepareGuilds.stop();
599
+ await prepareGuilds.stop();
491
600
  await hostedLeaveWorker?.stop();
492
601
  process.off("SIGINT", onSigint);
493
602
  process.off("SIGTERM", onSigterm);
@@ -495,6 +604,7 @@ async function startUnshardedBot(processEnvironment) {
495
604
  clearInterval(retentionCleanupTimer);
496
605
  if (premiumCatalogueTimer)
497
606
  clearInterval(premiumCatalogueTimer);
607
+ await creatorInvitePlatform?.stop();
498
608
  await scheduledTasks?.stop().catch((error) => {
499
609
  process.exitCode = 1;
500
610
  bootstrapLogger.error({ event: "scheduler.stop_failed", error: safeError(error) }, "Scheduled task runner shutdown failed");
@@ -566,7 +676,9 @@ async function startUnshardedBot(processEnvironment) {
566
676
  await commandsReady;
567
677
  hostedLeaveWorker?.start();
568
678
  scheduledTasks = createScheduledTaskRunner({
569
- database,
679
+ ...(mysql
680
+ ? { repository: mysql.scheduledTasks }
681
+ : { database: database }),
570
682
  modules: publicModules,
571
683
  route: createLocalScheduledTaskRoute({
572
684
  modules: publicModules,
@@ -578,7 +690,9 @@ async function startUnshardedBot(processEnvironment) {
578
690
  });
579
691
  scheduledTasks.start();
580
692
  guildCommandWorker = new GuildCommandWorker({
581
- database,
693
+ ...(mysql
694
+ ? { repositories: mysql.commandWorker }
695
+ : { database: database }),
582
696
  modules,
583
697
  logger: bootstrapLogger,
584
698
  workerId: `unsharded-command:${unshardedSupervisorId}`,
@@ -601,7 +715,7 @@ async function startUnshardedBot(processEnvironment) {
601
715
  if (environment.HELYX_GUILD_COMMAND_WRITES_ENABLED &&
602
716
  environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all") {
603
717
  void scheduleGuildCommandRelease({
604
- database,
718
+ ...(mysql ? mysql.commandRelease : { database: database }),
605
719
  applicationId: environment.DISCORD_CLIENT_ID,
606
720
  releaseId: environment.HELYX_GUILD_COMMAND_RELEASE_ID,
607
721
  })
@@ -636,18 +750,22 @@ async function startUnshardedBot(processEnvironment) {
636
750
  controlServer = await createBotControlServer({
637
751
  service: controlService,
638
752
  secret: environment.HELYX_BOT_CONTROL_SECRET,
639
- deliverHostedServerLeave: (input) => hostedLeaveWorker.deliverOperation(input),
640
- refreshHostedServerOwner: async (guildId) => {
641
- const guild = client.guilds.cache.get(guildId);
642
- if (!guild)
643
- throw new BotControlTransportError(404, "The hosted server is not installed");
644
- return hostedOwnerObserver.refreshOwner(guild);
645
- },
753
+ ...(hostedLeaveWorker && hostedOwnerObserver
754
+ ? {
755
+ deliverHostedServerLeave: (input) => hostedLeaveWorker.deliverOperation(input),
756
+ refreshHostedServerOwner: async (guildId) => {
757
+ const guild = client.guilds.cache.get(guildId);
758
+ if (!guild)
759
+ throw new BotControlTransportError(404, "The hosted server is not installed");
760
+ return hostedOwnerObserver.refreshOwner(guild);
761
+ },
762
+ }
763
+ : {}),
646
764
  logger: { level: environment.LOG_LEVEL },
647
765
  runtimeConfiguration: {
648
766
  hostedModerationEnabled: environment.HELYX_HOSTED_MODERATION_ENABLED,
649
767
  },
650
- 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", modules),
768
+ guildCommands: guildCommandReads,
651
769
  ...(ecko ? { ecko } : {}),
652
770
  });
653
771
  await controlServer.listen({
@@ -664,6 +782,7 @@ async function startUnshardedBot(processEnvironment) {
664
782
  }
665
783
  }
666
784
  catch (error) {
785
+ await prepareGuilds.stop();
667
786
  await scheduledTasks?.stop().catch(() => undefined);
668
787
  await guildCommandWorker?.stop().catch(() => undefined);
669
788
  await cloudConnector.stop().catch(() => undefined);
@@ -229,10 +229,16 @@ export function installInteractions(client, modules, registration, logger, servi
229
229
  await interaction.reply(staleComponentResponse());
230
230
  return;
231
231
  }
232
- if (modal?.contribution.acknowledgement === "deferred-ephemeral" &&
232
+ if (modal.contribution.acknowledgement !== undefined &&
233
233
  !interaction.deferred &&
234
234
  !interaction.replied) {
235
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
235
+ if (modal.contribution.acknowledgement === "deferred-update" &&
236
+ interaction.isFromMessage()) {
237
+ await interaction.deferUpdate();
238
+ }
239
+ else {
240
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
241
+ }
236
242
  }
237
243
  if (modal &&
238
244
  (await authorizeComponent(interaction, modal.moduleId, services, undefined, isInstallationIndependentModule(modulesById, modal.moduleId), modulesById.get(modal.moduleId)))) {
@@ -727,7 +733,14 @@ function normalizeAutocompleteChoices(choices) {
727
733
  }
728
734
  function createModalContext(interaction, services) {
729
735
  const updateResponse = interaction.isFromMessage()
730
- ? async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined)
736
+ ? async (response) => {
737
+ if (interaction.deferred || interaction.replied) {
738
+ await interaction.editReply(toUpdateOptions(response));
739
+ }
740
+ else {
741
+ await interaction.update(toUpdateOptions(response));
742
+ }
743
+ }
731
744
  : undefined;
732
745
  return {
733
746
  interactionId: interaction.id,
@@ -5,6 +5,6 @@ type CatalogueReader = (path: CatalogueLocation) => Promise<string>;
5
5
  * optional runtime allowlist. A package being installed is never sufficient
6
6
  * to make it executable: it must be named by one of these explicit sources.
7
7
  */
8
- export declare function resolveModulePackageSpecifiers(configuredPackages: readonly string[], bundledCatalogueUrl?: URL, reader?: CatalogueReader, selectedCataloguePath?: string): Promise<string[]>;
8
+ export declare function resolveModulePackageSpecifiers(configuredPackages: readonly string[], bundledCatalogueUrl?: URL, reader?: CatalogueReader, selectedCataloguePath?: string, runtimeProfile?: "hosted" | "self-hosted"): Promise<string[]>;
9
9
  export {};
10
10
  //# sourceMappingURL=module-configuration.d.ts.map
@@ -5,6 +5,7 @@ const officialModulePackage = /^@helyx\/module-[a-z0-9][a-z0-9-]*$/u;
5
5
  const modulePackageCatalogueSchema = z
6
6
  .object({
7
7
  schemaVersion: z.literal(1),
8
+ databaseEngine: z.enum(["postgresql", "mysql"]).optional(),
8
9
  packages: z
9
10
  .array(z.string().regex(officialModulePackage))
10
11
  .superRefine((packages, context) => {
@@ -23,8 +24,10 @@ const defaultCatalogueUrl = new URL("../hosted-modules.json", import.meta.url);
23
24
  * optional runtime allowlist. A package being installed is never sufficient
24
25
  * to make it executable: it must be named by one of these explicit sources.
25
26
  */
26
- export async function resolveModulePackageSpecifiers(configuredPackages, bundledCatalogueUrl = defaultCatalogueUrl, reader = (path) => readFile(path, "utf8"), selectedCataloguePath) {
27
- const bundledPackages = await readModuleCatalogue(bundledCatalogueUrl, reader, true, "hosted");
27
+ export async function resolveModulePackageSpecifiers(configuredPackages, bundledCatalogueUrl = defaultCatalogueUrl, reader = (path) => readFile(path, "utf8"), selectedCataloguePath, runtimeProfile = "hosted") {
28
+ const bundledPackages = runtimeProfile === "hosted"
29
+ ? await readModuleCatalogue(bundledCatalogueUrl, reader, true, "hosted")
30
+ : [];
28
31
  const selectedPackages = selectedCataloguePath
29
32
  ? await readModuleCatalogue(resolve(selectedCataloguePath), reader, false, "selected")
30
33
  : [];
@@ -1,13 +1,18 @@
1
- import { type DatabasePool } from "@helyx/database";
1
+ import { InstallationRepository, ModuleConfigurationRepository, ModuleLoggingPolicyRepository, type DatabasePool } from "@helyx/database";
2
+ import type { InstallationRepository as MysqlInstallationRepository, ModuleConfigurationRepository as MysqlModuleConfigurationRepository, ModuleLoggingPolicyRepository as MysqlModuleLoggingPolicyRepository } from "@helyx/database-mysql";
2
3
  import type { ModuleDefinition, ModuleLogDetail, ModuleLoggingService } from "@helyx/sdk";
3
4
  import type { Client } from "discord.js";
4
5
  import { type InteractionLogger } from "./interactions.js";
6
+ export interface ModuleLoggingRepositories {
7
+ installations: Pick<InstallationRepository, "isModuleEnabled"> & Pick<MysqlInstallationRepository, "isModuleEnabled">;
8
+ configuration: Pick<ModuleConfigurationRepository, "get"> & Pick<MysqlModuleConfigurationRepository, "get">;
9
+ policies: Pick<ModuleLoggingPolicyRepository, "get"> & Pick<MysqlModuleLoggingPolicyRepository, "get">;
10
+ }
5
11
  export declare class DiscordModuleLoggingService implements ModuleLoggingService {
6
12
  #private;
7
13
  private readonly client;
8
- private readonly database;
9
14
  private readonly logger;
10
- constructor(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger);
15
+ constructor(client: Client, databaseOrRepositories: DatabasePool | ModuleLoggingRepositories, modules: readonly ModuleDefinition[], logger: InteractionLogger);
11
16
  emit(input: {
12
17
  guildId: string;
13
18
  moduleId: string;
@@ -6,14 +6,21 @@ const LOGGING_MODULE_ID = "helyx.logging";
6
6
  const VIOLET = 0x7c3aed;
7
7
  export class DiscordModuleLoggingService {
8
8
  client;
9
- database;
10
9
  logger;
11
10
  #modules;
12
11
  #loggingInstalled;
13
- constructor(client, database, modules, logger) {
12
+ #repositories;
13
+ constructor(client, databaseOrRepositories, modules, logger) {
14
14
  this.client = client;
15
- this.database = database;
16
15
  this.logger = logger;
16
+ this.#repositories =
17
+ "installations" in databaseOrRepositories
18
+ ? databaseOrRepositories
19
+ : {
20
+ installations: new InstallationRepository(databaseOrRepositories),
21
+ configuration: new ModuleConfigurationRepository(databaseOrRepositories),
22
+ policies: new ModuleLoggingPolicyRepository(databaseOrRepositories),
23
+ };
17
24
  this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
18
25
  this.#loggingInstalled = this.#modules.has(LOGGING_MODULE_ID);
19
26
  }
@@ -26,13 +33,12 @@ export class DiscordModuleLoggingService {
26
33
  if (!source || !event) {
27
34
  throw new Error("Module emitted an undeclared logging event");
28
35
  }
29
- const installations = new InstallationRepository(this.database);
30
- if (!(await installations.isModuleEnabled(input.guildId, LOGGING_MODULE_ID))) {
36
+ if (!(await this.#repositories.installations.isModuleEnabled(input.guildId, LOGGING_MODULE_ID))) {
31
37
  return { delivered: false, retryable: false };
32
38
  }
33
39
  const [configuration, policy] = await Promise.all([
34
- new ModuleConfigurationRepository(this.database).get(input.guildId, LOGGING_MODULE_ID),
35
- new ModuleLoggingPolicyRepository(this.database).get(input.guildId, input.moduleId, input.eventId),
40
+ this.#repositories.configuration.get(input.guildId, LOGGING_MODULE_ID),
41
+ this.#repositories.policies.get(input.guildId, input.moduleId, input.eventId),
36
42
  ]);
37
43
  if (!(policy?.enabled ?? event.defaultEnabled)) {
38
44
  return { delivered: false, retryable: false };