@helyx/bot 1.0.1 → 1.2.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 (88) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +27 -2
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/developer-operations-module.d.ts +2 -0
  23. package/dist/developer-operations-module.js +136 -2
  24. package/dist/discord-analytics.d.ts +3 -0
  25. package/dist/discord-analytics.js +49 -5
  26. package/dist/discord-auto-moderation-service.d.ts +21 -0
  27. package/dist/discord-auto-moderation-service.js +425 -0
  28. package/dist/discord-guild-health.d.ts +2 -2
  29. package/dist/discord-member-enforcement-service.d.ts +26 -0
  30. package/dist/discord-member-enforcement-service.js +590 -0
  31. package/dist/discord-message-enforcement-service.d.ts +9 -0
  32. package/dist/discord-message-enforcement-service.js +102 -0
  33. package/dist/discord-violation-thread-service.d.ts +33 -0
  34. package/dist/discord-violation-thread-service.js +789 -0
  35. package/dist/ecko-evidence-receiver.d.ts +32 -0
  36. package/dist/ecko-evidence-receiver.js +173 -0
  37. package/dist/ecko-transport-auth.d.ts +19 -0
  38. package/dist/ecko-transport-auth.js +80 -0
  39. package/dist/ecko-transport-routes.d.ts +19 -0
  40. package/dist/ecko-transport-routes.js +130 -0
  41. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  42. package/dist/embedded-auto-moderation-pool.js +324 -0
  43. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  44. package/dist/embedded-auto-moderation-scanner.js +263 -0
  45. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  46. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  47. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  48. package/dist/embedded-auto-moderation-worker.js +13 -0
  49. package/dist/events.d.ts +5 -1
  50. package/dist/events.js +275 -9
  51. package/dist/guilds.d.ts +2 -1
  52. package/dist/guilds.js +164 -5
  53. package/dist/hosted-server-moderation.d.ts +100 -0
  54. package/dist/hosted-server-moderation.js +226 -0
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.js +101 -12
  57. package/dist/moderation-action-registry.d.ts +11 -0
  58. package/dist/moderation-action-registry.js +375 -0
  59. package/dist/moderation-case-disassociation.d.ts +13 -0
  60. package/dist/moderation-case-disassociation.js +83 -0
  61. package/dist/moderation-platform-services.d.ts +27 -0
  62. package/dist/moderation-platform-services.js +47 -0
  63. package/dist/modules.d.ts +6 -0
  64. package/dist/modules.js +65 -3
  65. package/dist/premium-activation-module.js +2 -0
  66. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  67. package/dist/privileged-message-metadata-cache.js +70 -0
  68. package/dist/scheduled-task-runner.js +24 -1
  69. package/dist/shard-contracts.d.ts +151 -1
  70. package/dist/shard-contracts.js +142 -1
  71. package/dist/shard-coordinator.d.ts +1 -1
  72. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  73. package/dist/shard-ecko-scanner-registry.js +96 -0
  74. package/dist/shard-router.d.ts +23 -0
  75. package/dist/shard-router.js +69 -5
  76. package/dist/shard-supervisor.d.ts +11 -0
  77. package/dist/shard-supervisor.js +72 -6
  78. package/dist/shard-worker.d.ts +16 -1
  79. package/dist/shard-worker.js +108 -10
  80. package/dist/transient-violation-evidence.d.ts +39 -0
  81. package/dist/transient-violation-evidence.js +132 -0
  82. package/documentation/README.md +2 -0
  83. package/documentation/examples/.env.example +13 -0
  84. package/documentation/guides/self-hosting.md +17 -1
  85. package/documentation/modules/auto-moderation.md +51 -0
  86. package/documentation/modules/moderation.md +42 -0
  87. package/documentation/reference/environment.md +11 -0
  88. package/package.json +17 -5
@@ -1,7 +1,7 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
- import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
2
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
3
  import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
- import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
4
+ import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
5
5
  import { Client, ShardingManager } from "discord.js";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { createRequire } from "node:module";
@@ -19,6 +19,11 @@ import { createPermissionsModule } from "./permissions-module.js";
19
19
  import { SHARD_CONTROL_MAX_BYTES, SHARD_CONTROL_TIMEOUT_MS, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
20
20
  import { RedisShardCoordinator, } from "./shard-coordinator.js";
21
21
  import { ShardControlRouter } from "./shard-router.js";
22
+ import { BotAutoModerationScannerRegistry } from "./auto-moderation-scanner-registry.js";
23
+ import { ShardEckoScannerRegistry } from "./shard-ecko-scanner-registry.js";
24
+ import { EckoEvidenceReceiver } from "./ecko-evidence-receiver.js";
25
+ import { EckoTransportAuthenticator } from "./ecko-transport-auth.js";
26
+ import { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
22
27
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
23
28
  import { SupervisorCloudDiscordDirectory } from "./supervisor-cloud-directory.js";
24
29
  import { createSupportModule } from "./support-module.js";
@@ -148,6 +153,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
148
153
  let deploymentOrder = 1;
149
154
  let shardCount = 1;
150
155
  let privacyService;
156
+ let leaderScannerRegistry;
151
157
  let manager;
152
158
  let scheduledTasks;
153
159
  let guildCommandWorker;
@@ -180,6 +186,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
180
186
  observedAt: new Date().toISOString(),
181
187
  };
182
188
  const supervisorDirectory = new SupervisorCloudDiscordDirectory(environment.DISCORD_TOKEN, database, environment.HELYX_OWNER_USER_IDS);
189
+ const eckoOperations = new EckoOperationsRepository(database);
183
190
  cloudConnector = new CloudConnectorController({
184
191
  environment,
185
192
  database,
@@ -288,6 +295,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
288
295
  assertStartupLeaseHealthy();
289
296
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
290
297
  runtimeAvailability = services.get(HELYX_SERVICE_NAMES.moduleAvailability);
298
+ leaderScannerRegistry = new BotAutoModerationScannerRegistry(publicModules, new ServiceRegistry(services), "hosted", undefined, environment.HELYX_DEPLOYMENT_ENVIRONMENT);
291
299
  if (numericWriteBehind)
292
300
  services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
293
301
  privacyService = new BotControlService(database, [
@@ -311,6 +319,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
311
319
  localLeases,
312
320
  dispatchLocal,
313
321
  privacy: privacyService,
322
+ leaderScannerRegistry,
314
323
  availability: availabilityService,
315
324
  localLeaderLease: leaderLease,
316
325
  ...(coordinator ? { coordinator } : {}),
@@ -405,6 +414,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
405
414
  localLeases,
406
415
  dispatchLocal,
407
416
  ...(privacyService ? { privacy: privacyService } : {}),
417
+ ...(leaderScannerRegistry ? { leaderScannerRegistry } : {}),
408
418
  availability: availabilityService,
409
419
  ...(leaderLease ? { localLeaderLease: leaderLease } : {}),
410
420
  ...(coordinator ? { coordinator } : {}),
@@ -437,13 +447,41 @@ export async function startShardSupervisor(environment, processEnvironment, work
437
447
  startScheduledTasks();
438
448
  assertStartupLeaseHealthy();
439
449
  if (environment.HELYX_BOT_CONTROL_SECRET) {
450
+ const supervisorEvidenceStore = new TransientViolationEvidenceStore();
451
+ const shardEckoRegistry = new ShardEckoScannerRegistry({
452
+ configurationRegistry: () => leaderScannerRegistry,
453
+ routeConfiguration: (input) => router.getHostedEckoConfigurationPage(input),
454
+ evidenceStore: supervisorEvidenceStore,
455
+ routeResult: (input) => router.acceptHostedEckoResult(input),
456
+ });
457
+ const ecko = environment.HELYX_ECKO_TRANSPORT_SECRET
458
+ ? {
459
+ authenticator: new EckoTransportAuthenticator(environment.HELYX_DEPLOYMENT_ENVIRONMENT, [
460
+ environment.HELYX_ECKO_TRANSPORT_SECRET,
461
+ ...(environment.HELYX_ECKO_TRANSPORT_PREVIOUS_SECRET
462
+ ? [environment.HELYX_ECKO_TRANSPORT_PREVIOUS_SECRET]
463
+ : []),
464
+ ]),
465
+ registry: shardEckoRegistry,
466
+ evidence: new EckoEvidenceReceiver(supervisorEvidenceStore, environment.HELYX_DEPLOYMENT_ENVIRONMENT, Buffer.from(environment.HELYX_ECKO_TRANSPORT_SECRET, "base64")),
467
+ gateState: () => eckoOperations.readGate(),
468
+ telemetry: {
469
+ accept: (batch) => eckoOperations.acceptTelemetry(batch),
470
+ },
471
+ }
472
+ : undefined;
440
473
  controlServer = await createBotControlServer({
441
474
  service: router,
442
475
  secret: environment.HELYX_BOT_CONTROL_SECRET,
476
+ ...shardHostedControlCallbacks(router),
443
477
  logger: { level: environment.LOG_LEVEL },
478
+ runtimeConfiguration: {
479
+ hostedModerationEnabled: environment.HELYX_HOSTED_MODERATION_ENABLED,
480
+ },
444
481
  shardHealth: buildHealth,
445
482
  readiness: buildDeploymentReadiness,
446
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),
484
+ ...(ecko ? { ecko } : {}),
447
485
  });
448
486
  await controlServer.listen({
449
487
  host: "::",
@@ -855,7 +893,10 @@ export async function startShardSupervisor(environment, processEnvironment, work
855
893
  });
856
894
  }
857
895
  async function executeLeaderControl(request) {
858
- if (!leaderLease || !privacyService)
896
+ if (!leaderLease ||
897
+ !privacyService ||
898
+ (request.operation.kind === "automod.enhanced.configuration.page" &&
899
+ !leaderScannerRegistry))
859
900
  return unavailableControl(request.requestId);
860
901
  if (request.generation !== leaderLease.generation ||
861
902
  request.fencingToken !== leaderLease.fencingToken ||
@@ -867,7 +908,22 @@ export async function startShardSupervisor(environment, processEnvironment, work
867
908
  type: "helyx.control.response",
868
909
  requestId: request.requestId,
869
910
  ok: true,
870
- result: await executeShardControlOperation(privacyService, request.operation),
911
+ result: request.operation.kind === "automod.enhanced.configuration.page"
912
+ ? await leaderScannerRegistry?.getConfigurationPage({
913
+ adapter: "hosted_ecko",
914
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
915
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
916
+ limit: request.operation.limit,
917
+ ...(request.operation.cursor
918
+ ? { cursor: request.operation.cursor }
919
+ : {}),
920
+ ...(request.operation.knownFingerprint
921
+ ? {
922
+ knownFingerprint: request.operation.knownFingerprint,
923
+ }
924
+ : {}),
925
+ })
926
+ : await executeShardControlOperation(privacyService, request.operation),
871
927
  };
872
928
  }
873
929
  catch (error) {
@@ -1454,6 +1510,12 @@ export async function startShardSupervisor(environment, processEnvironment, work
1454
1510
  logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
1455
1511
  }
1456
1512
  }
1513
+ export function shardHostedControlCallbacks(router) {
1514
+ return {
1515
+ deliverHostedServerLeave: (input) => router.deliverHostedServerLeave(input),
1516
+ refreshHostedServerOwner: (guildId) => router.refreshHostedServerOwner(guildId),
1517
+ };
1518
+ }
1457
1519
  export function splitNumericCommittedBatch(batch, index) {
1458
1520
  const mutation = batch.mutations[index];
1459
1521
  const result = batch.results[index];
@@ -1583,6 +1645,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1583
1645
  "HELYX_MESSAGE_CONTENT_ENABLED",
1584
1646
  "HELYX_GUILD_MEMBERS_ENABLED",
1585
1647
  "HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS",
1648
+ "HELYX_HOSTED_MODERATION_ENABLED",
1586
1649
  "HELYX_MODULE_CATALOGUE_PATH",
1587
1650
  "STRIPE_ENVIRONMENT",
1588
1651
  "HELYX_CLOUD_CONNECT_ENABLED",
@@ -1697,11 +1760,12 @@ function failedCloud(requestId, message) {
1697
1760
  }
1698
1761
  async function cleanupExpiredCoreRecords(database, logger) {
1699
1762
  try {
1700
- const [sessions, rateLimits, verificationAudit, commandRecords] = await Promise.all([
1763
+ const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
1701
1764
  new InteractionSessionRepository(database).deleteExpired(),
1702
1765
  new RateLimitRepository(database).deleteExpired(),
1703
1766
  new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
1704
1767
  new GuildCommandRepository(database).deleteExpired(12),
1768
+ new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
1705
1769
  ]);
1706
1770
  const commandRecordCount = commandRecords.history +
1707
1771
  commandRecords.idempotencyKeys +
@@ -1710,13 +1774,15 @@ async function cleanupExpiredCoreRecords(database, logger) {
1710
1774
  if (sessions > 0 ||
1711
1775
  rateLimits > 0 ||
1712
1776
  verificationAudit > 0 ||
1713
- commandRecordCount > 0)
1777
+ commandRecordCount > 0 ||
1778
+ hostedLeaveDeliveries > 0)
1714
1779
  logger.info({
1715
1780
  event: "database.retention_cleaned",
1716
1781
  interactionSessions: sessions,
1717
1782
  rateLimitWindows: rateLimits,
1718
1783
  verificationAuditEvents: verificationAudit,
1719
1784
  commandRecords: commandRecordCount,
1785
+ hostedLeaveDeliveries,
1720
1786
  }, "Expired core records removed");
1721
1787
  }
1722
1788
  catch (error) {
@@ -1,5 +1,7 @@
1
1
  import { type CommandRegistration } from "./interactions.js";
2
- import { type ShardIdentity, type ShardScheduledTaskRequest, type ShardScheduledTaskResponse } from "./shard-contracts.js";
2
+ import type { BotAutoModerationScannerRegistry } from "./auto-moderation-scanner-registry.js";
3
+ import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
4
+ import { type ShardControlOperation, type ShardIdentity, type ShardScheduledTaskRequest, type ShardScheduledTaskResponse } from "./shard-contracts.js";
3
5
  import { type BotHandle } from "./index.js";
4
6
  import { type ScheduledTaskRoute } from "./scheduled-task-runner.js";
5
7
  export declare const WORKER_ORPHAN_TERMINATION_BOUND_MS: number;
@@ -28,6 +30,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
28
30
  HELYX_SHARD_WORKER_DATABASE_POOL_MAX: number | undefined;
29
31
  DATABASE_URL: string;
30
32
  DATABASE_SSL: "disable" | "require" | "verify-full";
33
+ HELYX_RUNTIME_PROFILE: "hosted" | "self-hosted";
31
34
  DISCORD_TOKEN: string;
32
35
  DISCORD_CLIENT_ID: string;
33
36
  DISCORD_INTERNAL_GUILD_ID: string | undefined;
@@ -35,6 +38,8 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
35
38
  HELYX_MODULE_PACKAGES: string[];
36
39
  HELYX_MODULE_CATALOGUE_PATH: string | undefined;
37
40
  HELYX_DASHBOARD_PUBLIC_URL: string;
41
+ HELYX_DEPLOYMENT_ENVIRONMENT: string;
42
+ HELYX_HOSTED_MODERATION_ENABLED: boolean;
38
43
  HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
39
44
  HELYX_VERIFICATION_ENABLED: boolean;
40
45
  HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS: Record<string, string>;
@@ -46,6 +51,11 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
46
51
  HELYX_GUILD_COMMAND_WRITE_GUILD_IDS: string[];
47
52
  HELYX_PRIVACY_ENABLED: boolean;
48
53
  HELYX_MESSAGE_CONTENT_ENABLED: boolean;
54
+ HELYX_AUTOMOD_EMBEDDED_WORKERS: number;
55
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS: number;
56
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES: number;
57
+ HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD: number;
58
+ HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS: number;
49
59
  HELYX_GUILD_MEMBERS_ENABLED: boolean;
50
60
  HELYX_CLOUD_CONNECT_ENABLED: boolean;
51
61
  HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
@@ -65,6 +75,8 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
65
75
  HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS: number;
66
76
  STRIPE_ENVIRONMENT?: "sandbox" | "live" | undefined;
67
77
  HELYX_BOT_CONTROL_SECRET?: string | undefined;
78
+ HELYX_ECKO_TRANSPORT_SECRET?: string | undefined;
79
+ HELYX_ECKO_TRANSPORT_PREVIOUS_SECRET?: string | undefined;
68
80
  HELYX_SUPPORT_SERVICE_SECRET?: string | undefined;
69
81
  HELYX_VERIFICATION_INSTALLATION_ID?: string | undefined;
70
82
  HELYX_VERIFICATION_URL?: string | undefined;
@@ -90,4 +102,7 @@ export declare function shardWorkerCommandRegistration(input: {
90
102
  premiumActivationModuleId?: string;
91
103
  oauthModuleId?: string;
92
104
  }): CommandRegistration;
105
+ export declare function acceptEnhancedAutoModerationResult(registry: Pick<BotAutoModerationScannerRegistry, "acceptResult"> | undefined, evidenceStore: TransientViolationEvidenceStore | undefined, operation: Extract<ShardControlOperation, {
106
+ kind: "automod.enhanced.result.accept";
107
+ }>): Promise<import("@helyx/sdk").AutoModerationEnhancedResultOutcome>;
93
108
  //# sourceMappingURL=shard-worker.d.ts.map
@@ -4,14 +4,15 @@ import { checkDatabaseConnection, createDatabasePool, PremiumAllocationRepositor
4
4
  import { REVIEWED_PREMIUM_MODULE_IDS } from "@helyx/module-manifest";
5
5
  import { createRuntime } from "@helyx/framework";
6
6
  import { createPlatformServices, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
7
- import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
7
+ import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
8
8
  import { Client, Events, Partials } from "discord.js";
9
9
  import { monitorEventLoopDelay } from "node:perf_hooks";
10
10
  import pino from "pino";
11
11
  import { createCloudManagementModule } from "./cloud-management-module.js";
12
- import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
12
+ import { BotControlService, BotControlTransportError, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
13
13
  import { HttpVerificationService } from "./verification-service.js";
14
- import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
14
+ import { DiscordMessageDeliveryService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
15
+ import { DiscordModerationMemberEnforcementAdapter } from "./discord-member-enforcement-service.js";
15
16
  import { installGuildTracking, } from "./guilds.js";
16
17
  import { installInteractions, } from "./interactions.js";
17
18
  import { createManagementModule } from "./management-module.js";
@@ -24,6 +25,7 @@ import { DiscordPremiumGuildAuthority } from "./premium-guild-authority.js";
24
25
  import { assertReviewedPremiumCatalogue, requirePremiumProviderEnvironment, } from "./premium-access.js";
25
26
  import { WorkerCloudConnectorProxy } from "./shard-cloud-proxy.js";
26
27
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
28
+ import { installModerationPlatformServices } from "./moderation-platform-services.js";
27
29
  import { DiscordGuildHealthInspection } from "./discord-guild-health.js";
28
30
  import { DiscordAnalyticsInspection } from "./discord-analytics.js";
29
31
  import { DiscordInviteInspection } from "./discord-invites.js";
@@ -32,6 +34,7 @@ import { PostgresTriviaDataProvider } from "./trivia-data-provider.js";
32
34
  import { createDeveloperOperationsModule, WorkerShardHealthProvider, } from "./developer-operations-module.js";
33
35
  import { assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, guildIdToShardId, SHARD_LEASE_HEARTBEAT_TIMEOUT_MS, shardIdentitySchema, supervisorMessageSchema, } from "./shard-contracts.js";
34
36
  import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
37
+ import { HostedBotInviteGateClient, HostedServerLeaveWorker, HostedServerModerationClient, PostgresHostedGuildSafety, } from "./hosted-server-moderation.js";
35
38
  import { createSupportModule } from "./support-module.js";
36
39
  import { createOAuthModule } from "./oauth-module.js";
37
40
  import { createPrivacyModule } from "./privacy-module.js";
@@ -79,6 +82,16 @@ export async function startShardWorker(processEnvironment = process.env, identit
79
82
  ? {}
80
83
  : { maxConnections: environment.HELYX_SHARD_WORKER_DATABASE_POOL_MAX }),
81
84
  });
85
+ const hostedOwnerObserver = new PostgresHostedGuildSafety(database);
86
+ const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
87
+ ? hostedOwnerObserver
88
+ : undefined;
89
+ const developerModeration = environment.HELYX_HOSTED_MODERATION_ENABLED
90
+ ? new HostedServerModerationClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
91
+ : undefined;
92
+ const hostedInviteGate = environment.HELYX_HOSTED_MODERATION_ENABLED
93
+ ? new HostedBotInviteGateClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
94
+ : undefined;
82
95
  const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
83
96
  eventLoopDelay.enable();
84
97
  let status = "starting";
@@ -90,6 +103,10 @@ export async function startShardWorker(processEnvironment = process.env, identit
90
103
  let client;
91
104
  let cloudProxy;
92
105
  let shardHealthProvider;
106
+ let embeddedScanner;
107
+ let autoModerationScannerRegistry;
108
+ let violationEvidenceStore;
109
+ let hostedLeaveWorker;
93
110
  let leaseHeartbeatWatchdog;
94
111
  let forcedLeaseExit;
95
112
  let numericWriteBehind;
@@ -99,6 +116,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
99
116
  shuttingDown = true;
100
117
  status = "stopped";
101
118
  guildTracking?.stop();
119
+ await hostedLeaveWorker?.stop();
102
120
  if (healthTimer)
103
121
  clearInterval(healthTimer);
104
122
  leaseHeartbeatWatchdog?.stop();
@@ -108,6 +126,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
108
126
  await cloudProxy?.stop().catch(() => undefined);
109
127
  shardHealthProvider?.stop();
110
128
  await moduleEvents?.stop().catch(() => undefined);
129
+ await embeddedScanner?.stop().catch(() => undefined);
111
130
  if (runtimeStarted)
112
131
  await runtime?.stop().catch(() => undefined);
113
132
  await numericWriteBehind?.stop();
@@ -125,7 +144,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
125
144
  assertReviewedPremiumCatalogue(featureModules);
126
145
  client = new Client({
127
146
  rest: discordRestOptions,
128
- intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED, environment.HELYX_GUILD_MEMBERS_ENABLED),
147
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED, environment.HELYX_GUILD_MEMBERS_ENABLED, environment.HELYX_RUNTIME_PROFILE),
129
148
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
130
149
  allowedMentions: { parse: [] },
131
150
  shards: [identity.shardId],
@@ -192,6 +211,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
192
211
  processLabel: `shard worker ${identity.shardId}`,
193
212
  triviaData: new PostgresTriviaDataProvider(database),
194
213
  health: shardHealthProvider,
214
+ ...(developerModeration ? { moderation: developerModeration } : {}),
195
215
  })
196
216
  : undefined;
197
217
  const modules = [
@@ -232,11 +252,29 @@ export async function startShardWorker(processEnvironment = process.env, identit
232
252
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
233
253
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
234
254
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
235
- platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
255
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordModerationMemberEnforcementAdapter(client));
236
256
  const memberRoles = new DiscordMemberRoleService(client);
237
257
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
238
258
  platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
239
259
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
260
+ const moderationPlatformServices = installModerationPlatformServices({
261
+ services: platformServices,
262
+ serviceAccess,
263
+ client,
264
+ modules: publicModules,
265
+ runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
266
+ deploymentEnvironment: environment.HELYX_DEPLOYMENT_ENVIRONMENT,
267
+ embedded: {
268
+ workers: environment.HELYX_AUTOMOD_EMBEDDED_WORKERS,
269
+ maximumItems: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS,
270
+ maximumBytes: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES,
271
+ maximumItemsPerGuild: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD,
272
+ refreshIntervalMs: environment.HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS * 1_000,
273
+ },
274
+ });
275
+ embeddedScanner = moderationPlatformServices.embeddedScanner;
276
+ autoModerationScannerRegistry = moderationPlatformServices.scannerRegistry;
277
+ violationEvidenceStore = moderationPlatformServices.evidenceStore;
240
278
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
241
279
  platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
242
280
  if (serviceOptions.inviteFingerprintKey)
@@ -265,8 +303,12 @@ export async function startShardWorker(processEnvironment = process.env, identit
265
303
  shardId: identity.shardId,
266
304
  fencingToken: BigInt(identity.fencingToken),
267
305
  };
268
- const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount, environment.DISCORD_CLIENT_ID);
306
+ 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);
269
307
  guildTracking = prepareGuilds;
308
+ hostedLeaveWorker = new HostedServerLeaveWorker(database, client, logger, {
309
+ shardId: identity.shardId,
310
+ shardCount: identity.shardCount,
311
+ });
270
312
  const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
271
313
  ...(environment.DISCORD_INTERNAL_GUILD_ID
272
314
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -281,7 +323,10 @@ export async function startShardWorker(processEnvironment = process.env, identit
281
323
  : {}),
282
324
  ...(oauthModule ? { oauthModuleId: oauthModule.manifest.id } : {}),
283
325
  }), logger, serviceAccess);
284
- moduleEvents = installModuleEvents(client, publicModules, logger, serviceAccess);
326
+ moduleEvents = installModuleEvents(client, publicModules, logger, serviceAccess, {
327
+ runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
328
+ evidenceStore: moderationPlatformServices.evidenceStore,
329
+ });
285
330
  const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess, undefined, environment.DISCORD_INTERNAL_GUILD_ID &&
286
331
  environment.HELYX_SUPPORT_STAFF_CHANNEL_ID
287
332
  ? {
@@ -294,7 +339,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
294
339
  if (!parsed.success)
295
340
  return;
296
341
  if (parsed.data.type === "helyx.control.request") {
297
- void handleControl(identity, controlService, parsed.data, logger);
342
+ void handleControl(identity, controlService, hostedOwnerObserver, client, hostedLeaveWorker, autoModerationScannerRegistry, violationEvidenceStore, parsed.data, logger);
298
343
  }
299
344
  else if (parsed.data.type === "helyx.lease.heartbeat") {
300
345
  if (sameShardIdentity(parsed.data.identity, identity))
@@ -356,8 +401,12 @@ export async function startShardWorker(processEnvironment = process.env, identit
356
401
  await cloudProxy.start();
357
402
  await runtime.start();
358
403
  runtimeStarted = true;
404
+ await embeddedScanner?.synchronize().catch((error) => {
405
+ logger.warn({ event: "automod.embedded_sync_failed", error: safeError(error) }, "Embedded Auto Moderation scanner started in Standard fallback");
406
+ });
359
407
  await client.login(environment.DISCORD_TOKEN);
360
408
  await commandsReady;
409
+ hostedLeaveWorker?.start();
361
410
  healthTimer = setInterval(() => sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0)), HEALTH_INTERVAL_MS);
362
411
  healthTimer.unref();
363
412
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
@@ -481,7 +530,7 @@ export function shardWorkerCommandRegistration(input) {
481
530
  ]),
482
531
  };
483
532
  }
484
- async function handleControl(identity, service, request, logger) {
533
+ async function handleControl(identity, service, hostedOwnerObserver, client, hostedLeaveWorker, scannerRegistry, evidenceStore, request, logger) {
485
534
  let response;
486
535
  const guildId = controlOperationGuildId(request.operation);
487
536
  if (request.generation !== identity.generation ||
@@ -505,7 +554,13 @@ async function handleControl(identity, service, request, logger) {
505
554
  type: "helyx.control.response",
506
555
  requestId: request.requestId,
507
556
  ok: true,
508
- result: await executeShardControlOperation(service, request.operation),
557
+ result: request.operation.kind === "automod.enhanced.result.accept"
558
+ ? await acceptEnhancedAutoModerationResult(scannerRegistry, evidenceStore, request.operation)
559
+ : request.operation.kind === "hosted-server.leave.deliver"
560
+ ? await deliverHostedLeave(hostedLeaveWorker, request.operation)
561
+ : request.operation.kind === "hosted-server.owner.refresh"
562
+ ? await refreshHostedOwner(hostedOwnerObserver, client, request.operation.guildId)
563
+ : await executeShardControlOperation(service, request.operation),
509
564
  };
510
565
  }
511
566
  catch (error) {
@@ -550,6 +605,49 @@ async function handleControl(identity, service, request, logger) {
550
605
  }
551
606
  process.send?.(response);
552
607
  }
608
+ export async function acceptEnhancedAutoModerationResult(registry, evidenceStore, operation) {
609
+ if (!registry || !evidenceStore)
610
+ return {
611
+ outcome: "rejected",
612
+ code: "module_unavailable",
613
+ };
614
+ const resultGuildId = operation.result && typeof operation.result === "object"
615
+ ? operation.result["guildId"]
616
+ : undefined;
617
+ if (resultGuildId !== operation.guildId)
618
+ return { outcome: "rejected", code: "wrong_owner" };
619
+ const evidenceReference = operation.evidence
620
+ ? evidenceStore.capture({
621
+ source: "enhanced_scanner",
622
+ ...operation.evidence,
623
+ })
624
+ : null;
625
+ if (operation.evidence && !evidenceReference)
626
+ return {
627
+ outcome: "rejected",
628
+ code: "module_unavailable",
629
+ };
630
+ return registry.acceptResult({
631
+ adapter: "hosted_ecko",
632
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
633
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
634
+ result: operation.result,
635
+ ...(evidenceReference ? { evidenceReference } : {}),
636
+ });
637
+ }
638
+ function deliverHostedLeave(worker, input) {
639
+ if (!worker)
640
+ throw new BotControlTransportError(503, "Hosted server moderation delivery is unavailable");
641
+ return worker.deliverOperation(input);
642
+ }
643
+ function refreshHostedOwner(ownerObserver, client, guildId) {
644
+ if (!client)
645
+ throw new BotControlTransportError(503, "Hosted server owner refresh is unavailable");
646
+ const guild = client.guilds.cache.get(guildId);
647
+ if (!guild)
648
+ throw new BotControlTransportError(404, "The hosted server is not installed");
649
+ return ownerObserver.refreshOwner(guild);
650
+ }
553
651
  function health(identity, client, status, eventLoopDelayNanoseconds, queueDepth) {
554
652
  return {
555
653
  shardId: identity.shardId,
@@ -0,0 +1,39 @@
1
+ import { type ViolationEvidenceReference } from "@helyx/sdk";
2
+ export interface TransientViolationEvidenceStoreLimits {
3
+ entries?: number;
4
+ totalBytes?: number;
5
+ }
6
+ export type TransientEvidenceReadResult = {
7
+ outcome: "available";
8
+ content: string;
9
+ } | {
10
+ outcome: "unavailable";
11
+ } | {
12
+ outcome: "mismatch";
13
+ };
14
+ export type TransientViolationEvidenceCaptureInput = (Omit<Extract<ViolationEvidenceReference, {
15
+ source: "discord_auto_moderation";
16
+ }>, "key"> & {
17
+ content: string;
18
+ }) | (Omit<Extract<ViolationEvidenceReference, {
19
+ source: "enhanced_scanner";
20
+ }>, "key"> & {
21
+ content: string;
22
+ });
23
+ /**
24
+ * Bot-private, memory-only hand-off for message content. The opaque reference is
25
+ * safe to cross module boundaries; the content and this vault are not.
26
+ */
27
+ export declare class TransientViolationEvidenceStore {
28
+ #private;
29
+ private readonly now;
30
+ private readonly createKey;
31
+ constructor(now?: () => number, createKey?: () => string, limits?: TransientViolationEvidenceStoreLimits);
32
+ capture(input: TransientViolationEvidenceCaptureInput): ViolationEvidenceReference | null;
33
+ consume(reference: ViolationEvidenceReference): TransientEvidenceReadResult;
34
+ /** Read without discarding so a failed Discord send can be retried. */
35
+ read(reference: ViolationEvidenceReference): TransientEvidenceReadResult;
36
+ discard(reference: ViolationEvidenceReference): void;
37
+ get size(): number;
38
+ }
39
+ //# sourceMappingURL=transient-violation-evidence.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { VIOLATION_THREAD_LIMITS, } from "@helyx/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ const MAX_EVIDENCE_ENTRIES = 10_000;
4
+ const MAX_EVIDENCE_TOTAL_BYTES = 16 * 1_024 * 1_024;
5
+ /**
6
+ * Bot-private, memory-only hand-off for message content. The opaque reference is
7
+ * safe to cross module boundaries; the content and this vault are not.
8
+ */
9
+ export class TransientViolationEvidenceStore {
10
+ now;
11
+ createKey;
12
+ #records = new Map();
13
+ #maximumEntries;
14
+ #maximumTotalBytes;
15
+ #totalBytes = 0;
16
+ constructor(now = Date.now, createKey = randomUUID, limits = {}) {
17
+ this.now = now;
18
+ this.createKey = createKey;
19
+ this.#maximumEntries = boundedPositiveInteger(limits.entries ?? MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_ENTRIES, "entry limit");
20
+ this.#maximumTotalBytes = boundedPositiveInteger(limits.totalBytes ?? MAX_EVIDENCE_TOTAL_BYTES, MAX_EVIDENCE_TOTAL_BYTES, "byte limit");
21
+ }
22
+ capture(input) {
23
+ validateIdentity(input.guildId);
24
+ validateIdentity(input.channelId);
25
+ if (input.messageId !== null)
26
+ validateIdentity(input.messageId);
27
+ validateIdentity(input.authorUserId);
28
+ validateBinding(input.sourceEventId, "source event");
29
+ if (input.source === "enhanced_scanner")
30
+ validateBinding(input.configurationRevision, "configuration revision");
31
+ const contentBytes = Buffer.byteLength(input.content, "utf8");
32
+ if (input.content.length < 1 ||
33
+ input.content.length > VIOLATION_THREAD_LIMITS.evidenceCharacters ||
34
+ contentBytes > VIOLATION_THREAD_LIMITS.evidenceUtf8Bytes)
35
+ return null;
36
+ this.#pruneExpired();
37
+ if (this.#records.size >= this.#maximumEntries ||
38
+ this.#totalBytes + contentBytes > this.#maximumTotalBytes)
39
+ return null;
40
+ const key = this.createKey();
41
+ if (this.#records.has(key))
42
+ return null;
43
+ const base = {
44
+ key,
45
+ sourceEventId: input.sourceEventId,
46
+ guildId: input.guildId,
47
+ channelId: input.channelId,
48
+ messageId: input.messageId,
49
+ authorUserId: input.authorUserId,
50
+ };
51
+ const reference = Object.freeze(input.source === "enhanced_scanner"
52
+ ? {
53
+ ...base,
54
+ source: input.source,
55
+ configurationRevision: input.configurationRevision,
56
+ }
57
+ : { ...base, source: input.source });
58
+ this.#records.set(reference.key, {
59
+ reference,
60
+ content: input.content,
61
+ bytes: contentBytes,
62
+ expiresAt: this.now() + VIOLATION_THREAD_LIMITS.evidenceLifetimeSeconds * 1_000,
63
+ });
64
+ this.#totalBytes += contentBytes;
65
+ return reference;
66
+ }
67
+ consume(reference) {
68
+ const result = this.read(reference);
69
+ if (result.outcome === "available")
70
+ this.discard(reference);
71
+ return result;
72
+ }
73
+ /** Read without discarding so a failed Discord send can be retried. */
74
+ read(reference) {
75
+ const record = this.#records.get(reference.key);
76
+ if (!record)
77
+ return { outcome: "unavailable" };
78
+ if (record.expiresAt <= this.now()) {
79
+ this.#delete(reference.key, record);
80
+ return { outcome: "unavailable" };
81
+ }
82
+ if (!sameReference(record.reference, reference))
83
+ return { outcome: "mismatch" };
84
+ return { outcome: "available", content: record.content };
85
+ }
86
+ discard(reference) {
87
+ const record = this.#records.get(reference.key);
88
+ if (record && sameReference(record.reference, reference))
89
+ this.#delete(reference.key, record);
90
+ }
91
+ get size() {
92
+ this.#pruneExpired();
93
+ return this.#records.size;
94
+ }
95
+ #pruneExpired() {
96
+ const now = this.now();
97
+ for (const [key, record] of this.#records)
98
+ if (record.expiresAt <= now)
99
+ this.#delete(key, record);
100
+ }
101
+ #delete(key, record) {
102
+ if (!this.#records.delete(key))
103
+ return;
104
+ this.#totalBytes = Math.max(0, this.#totalBytes - record.bytes);
105
+ }
106
+ }
107
+ function boundedPositiveInteger(value, maximum, subject) {
108
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
109
+ throw new RangeError(`Violation evidence ${subject} is invalid`);
110
+ return value;
111
+ }
112
+ function sameReference(left, right) {
113
+ return (left.source === right.source &&
114
+ left.key === right.key &&
115
+ left.sourceEventId === right.sourceEventId &&
116
+ left.guildId === right.guildId &&
117
+ left.channelId === right.channelId &&
118
+ left.messageId === right.messageId &&
119
+ left.authorUserId === right.authorUserId &&
120
+ (left.source !== "enhanced_scanner" ||
121
+ (right.source === "enhanced_scanner" &&
122
+ left.configurationRevision === right.configurationRevision)));
123
+ }
124
+ function validateIdentity(value) {
125
+ if (!/^\d{17,20}$/u.test(value))
126
+ throw new Error("Violation evidence identity is invalid");
127
+ }
128
+ function validateBinding(value, subject) {
129
+ if (value.trim().length < 1 || value.length > 160 || /[\r\n]/u.test(value))
130
+ throw new Error(`Violation evidence ${subject} is invalid`);
131
+ }
132
+ //# sourceMappingURL=transient-violation-evidence.js.map