@helyx/bot 1.3.1 → 1.5.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 (97) hide show
  1. package/CHANGELOG.md +100 -28
  2. package/README.md +65 -11
  3. package/dist/channel-audit-configuration.d.ts +7 -0
  4. package/dist/channel-audit-configuration.js +40 -0
  5. package/dist/channel-audit-control.d.ts +14 -0
  6. package/dist/channel-audit-control.js +61 -0
  7. package/dist/channel-audit-permissions.d.ts +56 -0
  8. package/dist/channel-audit-permissions.js +417 -0
  9. package/dist/channel-audit-routes.d.ts +4 -0
  10. package/dist/channel-audit-routes.js +42 -0
  11. package/dist/cloud-connector.d.ts +11 -4
  12. package/dist/cloud-connector.js +54 -15
  13. package/dist/cloud-control-dispatcher.d.ts +8 -3
  14. package/dist/cloud-control-dispatcher.js +84 -4
  15. package/dist/control-errors.d.ts +12 -0
  16. package/dist/control-errors.js +32 -0
  17. package/dist/control-server-schemas.d.ts +84 -0
  18. package/dist/control-server-schemas.js +101 -0
  19. package/dist/control-server.d.ts +25 -8
  20. package/dist/control-server.js +101 -152
  21. package/dist/core-retention.d.ts +26 -0
  22. package/dist/core-retention.js +49 -0
  23. package/dist/creator-guild-join-service.d.ts +25 -0
  24. package/dist/creator-guild-join-service.js +218 -0
  25. package/dist/creator-invite-service.d.ts +37 -0
  26. package/dist/creator-invite-service.js +686 -0
  27. package/dist/creator-onboarding-control.d.ts +12 -0
  28. package/dist/creator-onboarding-control.js +65 -0
  29. package/dist/creator-onboarding-platform.d.ts +20 -0
  30. package/dist/creator-onboarding-platform.js +58 -0
  31. package/dist/dashboard-action-control.d.ts +2 -2
  32. package/dist/dashboard-action-control.js +2 -3
  33. package/dist/database-bootstrap.d.ts +36 -0
  34. package/dist/database-bootstrap.js +101 -0
  35. package/dist/developer-operations-module.d.ts +1 -0
  36. package/dist/developer-operations-module.js +34 -16
  37. package/dist/discord-channel-audit.d.ts +9 -0
  38. package/dist/discord-channel-audit.js +195 -0
  39. package/dist/discord-guild-health.d.ts +2 -2
  40. package/dist/discord-owned-message-service.d.ts +1 -0
  41. package/dist/discord-owned-message-service.js +3 -0
  42. package/dist/discord-role-safety.d.ts +4 -0
  43. package/dist/discord-role-safety.js +30 -0
  44. package/dist/events.d.ts +2 -0
  45. package/dist/events.js +5 -20
  46. package/dist/guild-command-read-service.d.ts +8 -3
  47. package/dist/guild-command-read-service.js +14 -7
  48. package/dist/guild-command-release.d.ts +15 -8
  49. package/dist/guild-command-release.js +10 -3
  50. package/dist/guild-command-worker.d.ts +17 -4
  51. package/dist/guild-command-worker.js +17 -6
  52. package/dist/guilds.d.ts +24 -3
  53. package/dist/guilds.js +185 -64
  54. package/dist/hosted-server-moderation.js +10 -4
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +200 -82
  57. package/dist/interactions.js +16 -3
  58. package/dist/module-configuration.d.ts +1 -1
  59. package/dist/module-configuration.js +5 -2
  60. package/dist/module-logging.d.ts +8 -3
  61. package/dist/module-logging.js +13 -7
  62. package/dist/modules.d.ts +1 -0
  63. package/dist/modules.js +8 -1
  64. package/dist/mysql-runtime-persistence.d.ts +46 -0
  65. package/dist/mysql-runtime-persistence.js +91 -0
  66. package/dist/oauth-module.js +2 -2
  67. package/dist/oauth-service.d.ts +7 -0
  68. package/dist/oauth-service.js +3 -0
  69. package/dist/scheduled-task-runner.d.ts +9 -85
  70. package/dist/scheduled-task-runner.js +1 -1
  71. package/dist/shard-contracts.d.ts +222 -0
  72. package/dist/shard-contracts.js +52 -1
  73. package/dist/shard-router.d.ts +4 -1
  74. package/dist/shard-router.js +19 -0
  75. package/dist/shard-supervisor.js +43 -9
  76. package/dist/shard-worker.d.ts +5 -2
  77. package/dist/shard-worker.js +41 -5
  78. package/dist/supervisor-cloud-directory.d.ts +9 -3
  79. package/dist/supervisor-cloud-directory.js +8 -12
  80. package/dist/trivia-data-provider.d.ts +11 -0
  81. package/dist/trivia-data-provider.js +46 -26
  82. package/dist/verification-service.d.ts +2 -2
  83. package/dist/verification-service.js +6 -3
  84. package/documentation/README.md +5 -3
  85. package/documentation/examples/.env.example +15 -11
  86. package/documentation/guides/getting-started.md +29 -12
  87. package/documentation/guides/privacy-and-support.md +6 -5
  88. package/documentation/guides/self-hosting.md +16 -11
  89. package/documentation/modules/auto-moderation.md +7 -5
  90. package/documentation/modules/building-a-module.md +48 -2
  91. package/documentation/modules/moderation.md +5 -5
  92. package/documentation/modules/publishing-a-module.md +6 -4
  93. package/documentation/modules/testing-a-module.md +39 -4
  94. package/documentation/reference/environment.md +43 -9
  95. package/documentation/runbooks/backup-and-recovery.md +26 -7
  96. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  97. 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,13 +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
- recordRecoveryOwnership: { mode: "single-worker" },
374
- activationServices: serviceAccess,
375
- })) {
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) {
376
445
  platformServices.set(name, service);
377
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
+ });
378
456
  await serviceAccess
379
457
  .get(HELYX_SERVICE_NAMES.moduleAvailability)
380
458
  .refresh();
@@ -386,6 +464,10 @@ async function startUnshardedBot(processEnvironment) {
386
464
  internalSecret: environment.HELYX_VERIFICATION_INTERNAL_SECRET,
387
465
  installationId: environment.HELYX_VERIFICATION_INSTALLATION_ID,
388
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 }),
389
471
  }));
390
472
  platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
391
473
  }
@@ -400,8 +482,29 @@ async function startUnshardedBot(processEnvironment) {
400
482
  platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordModerationMemberEnforcementAdapter(client));
401
483
  const memberRoles = new DiscordMemberRoleService(client);
402
484
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
403
- 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));
404
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
+ });
405
508
  const moderationPlatformServices = await installModerationPlatformServices({
406
509
  services: platformServices,
407
510
  serviceAccess,
@@ -422,7 +525,7 @@ async function startUnshardedBot(processEnvironment) {
422
525
  if (serviceOptions.inviteFingerprintKey)
423
526
  platformServices.set(HELYX_SERVICE_NAMES.discordInvites, new DiscordInviteInspection(client, serviceOptions.inviteFingerprintKey));
424
527
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
425
- 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));
426
529
  }
427
530
  const runtime = createRuntime({
428
531
  modules,
@@ -438,14 +541,18 @@ async function startUnshardedBot(processEnvironment) {
438
541
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
439
542
  ...(operationsModule ? [operationsModule.manifest.id] : []),
440
543
  ]);
441
- 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);
442
- 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;
443
548
  const commandsReady = installInteractions(client, modules, {
444
549
  globalCommandMode: environment.HELYX_GLOBAL_COMMAND_MODE,
445
550
  ...(environment.DISCORD_INTERNAL_GUILD_ID
446
551
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
447
552
  : {}),
448
- 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,
449
556
  prepare: prepareGuilds,
450
557
  selfAuthorizingGuildModuleIds: new Set([
451
558
  supportModule.manifest.id,
@@ -460,7 +567,7 @@ async function startUnshardedBot(processEnvironment) {
460
567
  runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
461
568
  evidenceStore: moderationPlatformServices.evidenceStore,
462
569
  });
463
- const controlService = new BotControlService(database, [
570
+ const controlService = new BotControlService(mysql?.control ?? database, [
464
571
  ...publicModules,
465
572
  gamingStatisticsRecordOwner,
466
573
  achievementContributionRecordOwner,
@@ -481,14 +588,15 @@ async function startUnshardedBot(processEnvironment) {
481
588
  channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
482
589
  }
483
590
  : undefined, undefined, undefined, featureModules);
484
- 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);
485
593
  let controlServer;
486
594
  let shuttingDown = false;
487
595
  async function shutdown(reason = "programmatic") {
488
596
  if (shuttingDown)
489
597
  return;
490
598
  shuttingDown = true;
491
- prepareGuilds.stop();
599
+ await prepareGuilds.stop();
492
600
  await hostedLeaveWorker?.stop();
493
601
  process.off("SIGINT", onSigint);
494
602
  process.off("SIGTERM", onSigterm);
@@ -496,6 +604,7 @@ async function startUnshardedBot(processEnvironment) {
496
604
  clearInterval(retentionCleanupTimer);
497
605
  if (premiumCatalogueTimer)
498
606
  clearInterval(premiumCatalogueTimer);
607
+ await creatorInvitePlatform?.stop();
499
608
  await scheduledTasks?.stop().catch((error) => {
500
609
  process.exitCode = 1;
501
610
  bootstrapLogger.error({ event: "scheduler.stop_failed", error: safeError(error) }, "Scheduled task runner shutdown failed");
@@ -567,7 +676,9 @@ async function startUnshardedBot(processEnvironment) {
567
676
  await commandsReady;
568
677
  hostedLeaveWorker?.start();
569
678
  scheduledTasks = createScheduledTaskRunner({
570
- database,
679
+ ...(mysql
680
+ ? { repository: mysql.scheduledTasks }
681
+ : { database: database }),
571
682
  modules: publicModules,
572
683
  route: createLocalScheduledTaskRoute({
573
684
  modules: publicModules,
@@ -579,7 +690,9 @@ async function startUnshardedBot(processEnvironment) {
579
690
  });
580
691
  scheduledTasks.start();
581
692
  guildCommandWorker = new GuildCommandWorker({
582
- database,
693
+ ...(mysql
694
+ ? { repositories: mysql.commandWorker }
695
+ : { database: database }),
583
696
  modules,
584
697
  logger: bootstrapLogger,
585
698
  workerId: `unsharded-command:${unshardedSupervisorId}`,
@@ -602,7 +715,7 @@ async function startUnshardedBot(processEnvironment) {
602
715
  if (environment.HELYX_GUILD_COMMAND_WRITES_ENABLED &&
603
716
  environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all") {
604
717
  void scheduleGuildCommandRelease({
605
- database,
718
+ ...(mysql ? mysql.commandRelease : { database: database }),
606
719
  applicationId: environment.DISCORD_CLIENT_ID,
607
720
  releaseId: environment.HELYX_GUILD_COMMAND_RELEASE_ID,
608
721
  })
@@ -637,18 +750,22 @@ async function startUnshardedBot(processEnvironment) {
637
750
  controlServer = await createBotControlServer({
638
751
  service: controlService,
639
752
  secret: environment.HELYX_BOT_CONTROL_SECRET,
640
- deliverHostedServerLeave: (input) => hostedLeaveWorker.deliverOperation(input),
641
- refreshHostedServerOwner: async (guildId) => {
642
- const guild = client.guilds.cache.get(guildId);
643
- if (!guild)
644
- throw new BotControlTransportError(404, "The hosted server is not installed");
645
- return hostedOwnerObserver.refreshOwner(guild);
646
- },
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
+ : {}),
647
764
  logger: { level: environment.LOG_LEVEL },
648
765
  runtimeConfiguration: {
649
766
  hostedModerationEnabled: environment.HELYX_HOSTED_MODERATION_ENABLED,
650
767
  },
651
- 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,
652
769
  ...(ecko ? { ecko } : {}),
653
770
  });
654
771
  await controlServer.listen({
@@ -665,6 +782,7 @@ async function startUnshardedBot(processEnvironment) {
665
782
  }
666
783
  }
667
784
  catch (error) {
785
+ await prepareGuilds.stop();
668
786
  await scheduledTasks?.stop().catch(() => undefined);
669
787
  await guildCommandWorker?.stop().catch(() => undefined);
670
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 };