@helyx/bot 1.1.0 → 1.2.1

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 (85) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +16 -1
  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/discord-analytics.d.ts +3 -0
  23. package/dist/discord-analytics.js +49 -5
  24. package/dist/discord-auto-moderation-service.d.ts +21 -0
  25. package/dist/discord-auto-moderation-service.js +425 -0
  26. package/dist/discord-guild-health.d.ts +2 -2
  27. package/dist/discord-member-enforcement-service.d.ts +26 -0
  28. package/dist/discord-member-enforcement-service.js +590 -0
  29. package/dist/discord-message-enforcement-service.d.ts +9 -0
  30. package/dist/discord-message-enforcement-service.js +102 -0
  31. package/dist/discord-violation-thread-service.d.ts +33 -0
  32. package/dist/discord-violation-thread-service.js +789 -0
  33. package/dist/ecko-evidence-receiver.d.ts +32 -0
  34. package/dist/ecko-evidence-receiver.js +173 -0
  35. package/dist/ecko-transport-auth.d.ts +19 -0
  36. package/dist/ecko-transport-auth.js +80 -0
  37. package/dist/ecko-transport-routes.d.ts +19 -0
  38. package/dist/ecko-transport-routes.js +130 -0
  39. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  40. package/dist/embedded-auto-moderation-pool.js +324 -0
  41. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  42. package/dist/embedded-auto-moderation-scanner.js +263 -0
  43. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  44. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  45. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  46. package/dist/embedded-auto-moderation-worker.js +13 -0
  47. package/dist/events.d.ts +5 -1
  48. package/dist/events.js +243 -4
  49. package/dist/guilds.d.ts +2 -2
  50. package/dist/guilds.js +14 -8
  51. package/dist/hosted-server-moderation.d.ts +16 -5
  52. package/dist/hosted-server-moderation.js +32 -4
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +84 -14
  55. package/dist/moderation-action-registry.d.ts +11 -0
  56. package/dist/moderation-action-registry.js +375 -0
  57. package/dist/moderation-case-disassociation.d.ts +13 -0
  58. package/dist/moderation-case-disassociation.js +83 -0
  59. package/dist/moderation-platform-services.d.ts +27 -0
  60. package/dist/moderation-platform-services.js +50 -0
  61. package/dist/modules.d.ts +6 -0
  62. package/dist/modules.js +65 -3
  63. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  64. package/dist/privileged-message-metadata-cache.js +70 -0
  65. package/dist/scheduled-task-runner.js +24 -1
  66. package/dist/shard-contracts.d.ts +151 -1
  67. package/dist/shard-contracts.js +142 -1
  68. package/dist/shard-coordinator.d.ts +1 -1
  69. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  70. package/dist/shard-ecko-scanner-registry.js +96 -0
  71. package/dist/shard-router.d.ts +23 -0
  72. package/dist/shard-router.js +69 -5
  73. package/dist/shard-supervisor.d.ts +11 -0
  74. package/dist/shard-supervisor.js +67 -4
  75. package/dist/shard-worker.d.ts +15 -1
  76. package/dist/shard-worker.js +95 -17
  77. package/dist/transient-violation-evidence.d.ts +39 -0
  78. package/dist/transient-violation-evidence.js +132 -0
  79. package/documentation/README.md +2 -0
  80. package/documentation/examples/.env.example +12 -0
  81. package/documentation/guides/self-hosting.md +17 -1
  82. package/documentation/modules/auto-moderation.md +51 -0
  83. package/documentation/modules/moderation.md +42 -0
  84. package/documentation/reference/environment.md +11 -0
  85. package/package.json +17 -5
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { StatusHeartbeatReporter } from "@helyx/status-client";
3
3
  import { safeError, ServiceRegistry } from "@helyx/core";
4
- import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, PremiumAllocationRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
5
5
  import { createRuntime } from "@helyx/framework";
6
6
  import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
7
7
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
@@ -19,9 +19,10 @@ import { loadConfiguredModulePackages, platformServiceOptions, } from "./modules
19
19
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
20
20
  import { createPermissionsModule } from "./permissions-module.js";
21
21
  import { createManagementModule } from "./management-module.js";
22
- import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
22
+ import { DiscordMessageDeliveryService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
23
+ import { DiscordModerationMemberEnforcementAdapter } from "./discord-member-enforcement-service.js";
23
24
  import { DiscordModuleLoggingService } from "./module-logging.js";
24
- import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
25
+ import { BotControlService, BotControlTransportError, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
25
26
  import { HttpVerificationService } from "./verification-service.js";
26
27
  import { startShardSupervisor } from "./shard-supervisor.js";
27
28
  import { parseShardWorkerLaunchContract, startShardWorker, } from "./shard-worker.js";
@@ -30,6 +31,9 @@ import { shardHealthSnapshotSchema, } from "./shard-contracts.js";
30
31
  import { createSupportModule } from "./support-module.js";
31
32
  import { createOAuthModule } from "./oauth-module.js";
32
33
  import { createPrivacyModule } from "./privacy-module.js";
34
+ import { installModerationPlatformServices } from "./moderation-platform-services.js";
35
+ import { EckoEvidenceReceiver } from "./ecko-evidence-receiver.js";
36
+ import { EckoTransportAuthenticator } from "./ecko-transport-auth.js";
33
37
  import { REVIEWED_PREMIUM_MODULE_IDS } from "@helyx/module-manifest";
34
38
  import { createPremiumActivationModule } from "./premium-activation-module.js";
35
39
  import { DiscordPremiumGuildAuthority } from "./premium-guild-authority.js";
@@ -120,8 +124,10 @@ async function startUnshardedBot(processEnvironment) {
120
124
  sslMode: environment.DATABASE_SSL,
121
125
  applicationName: "helyx-bot",
122
126
  });
127
+ const hostedOwnerObserver = new PostgresHostedGuildSafety(database);
128
+ const eckoOperations = new EckoOperationsRepository(database);
123
129
  const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
124
- ? new PostgresHostedGuildSafety(database)
130
+ ? hostedOwnerObserver
125
131
  : undefined;
126
132
  const developerModeration = environment.HELYX_HOSTED_MODERATION_ENABLED
127
133
  ? new HostedServerModerationClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
@@ -226,7 +232,7 @@ async function startUnshardedBot(processEnvironment) {
226
232
  premiumCatalogueTimer.unref();
227
233
  const client = new Client({
228
234
  rest: discordRestOptions,
229
- intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED, environment.HELYX_GUILD_MEMBERS_ENABLED),
235
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED, environment.HELYX_GUILD_MEMBERS_ENABLED, environment.HELYX_RUNTIME_PROFILE),
230
236
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
231
237
  allowedMentions: { parse: [] },
232
238
  });
@@ -390,11 +396,26 @@ async function startUnshardedBot(processEnvironment) {
390
396
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
391
397
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
392
398
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
393
- platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
399
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordModerationMemberEnforcementAdapter(client));
394
400
  const memberRoles = new DiscordMemberRoleService(client);
395
401
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
396
402
  platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
397
403
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
404
+ const moderationPlatformServices = await installModerationPlatformServices({
405
+ services: platformServices,
406
+ serviceAccess,
407
+ client,
408
+ modules: publicModules,
409
+ runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
410
+ deploymentEnvironment: environment.HELYX_DEPLOYMENT_ENVIRONMENT,
411
+ embedded: {
412
+ workers: environment.HELYX_AUTOMOD_EMBEDDED_WORKERS,
413
+ maximumItems: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS,
414
+ maximumBytes: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_BYTES,
415
+ maximumItemsPerGuild: environment.HELYX_AUTOMOD_EMBEDDED_QUEUE_ITEMS_PER_GUILD,
416
+ refreshIntervalMs: environment.HELYX_AUTOMOD_EMBEDDED_REFRESH_SECONDS * 1_000,
417
+ },
418
+ });
398
419
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
399
420
  platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
400
421
  if (serviceOptions.inviteFingerprintKey)
@@ -416,10 +437,8 @@ async function startUnshardedBot(processEnvironment) {
416
437
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
417
438
  ...(operationsModule ? [operationsModule.manifest.id] : []),
418
439
  ]);
419
- const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate);
420
- const hostedLeaveWorker = hostedSafety
421
- ? new HostedServerLeaveWorker(database, client, bootstrapLogger)
422
- : undefined;
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);
423
442
  const commandsReady = installInteractions(client, modules, {
424
443
  globalCommandMode: environment.HELYX_GLOBAL_COMMAND_MODE,
425
444
  ...(environment.DISCORD_INTERNAL_GUILD_ID
@@ -436,7 +455,10 @@ async function startUnshardedBot(processEnvironment) {
436
455
  ...(operationsModule ? [operationsModule.manifest.id] : []),
437
456
  ]),
438
457
  }, bootstrapLogger, serviceAccess);
439
- const moduleEvents = installModuleEvents(client, publicModules, bootstrapLogger, serviceAccess);
458
+ const moduleEvents = installModuleEvents(client, publicModules, bootstrapLogger, serviceAccess, {
459
+ runtimeProfile: environment.HELYX_RUNTIME_PROFILE,
460
+ evidenceStore: moderationPlatformServices.evidenceStore,
461
+ });
440
462
  const controlService = new BotControlService(database, [
441
463
  ...publicModules,
442
464
  gamingStatisticsRecordOwner,
@@ -498,6 +520,12 @@ async function startUnshardedBot(processEnvironment) {
498
520
  process.exitCode = 1;
499
521
  bootstrapLogger.error({ event: "module_events.stop_failed", error: safeError(error) }, "Module event shutdown failed");
500
522
  });
523
+ await moderationPlatformServices.embeddedScanner
524
+ ?.stop()
525
+ .catch((error) => {
526
+ process.exitCode = 1;
527
+ bootstrapLogger.error({ event: "automod.embedded_stop_failed", error: safeError(error) }, "Embedded Auto Moderation scanner shutdown failed");
528
+ });
501
529
  await runtime.stop().catch((error) => {
502
530
  process.exitCode = 1;
503
531
  bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
@@ -529,6 +557,11 @@ async function startUnshardedBot(processEnvironment) {
529
557
  process.once("SIGTERM", onSigterm);
530
558
  try {
531
559
  await runtime.start();
560
+ await moderationPlatformServices.embeddedScanner
561
+ ?.synchronize()
562
+ .catch((error) => {
563
+ bootstrapLogger.warn({ event: "automod.embedded_sync_failed", error: safeError(error) }, "Embedded Auto Moderation scanner started in Standard fallback");
564
+ });
532
565
  await client.login(environment.DISCORD_TOKEN);
533
566
  await commandsReady;
534
567
  hostedLeaveWorker?.start();
@@ -584,11 +617,38 @@ async function startUnshardedBot(processEnvironment) {
584
617
  }
585
618
  await cloudConnector.start();
586
619
  if (environment.HELYX_BOT_CONTROL_SECRET) {
620
+ const ecko = environment.HELYX_ECKO_TRANSPORT_SECRET
621
+ ? {
622
+ authenticator: new EckoTransportAuthenticator(environment.HELYX_DEPLOYMENT_ENVIRONMENT, [
623
+ environment.HELYX_ECKO_TRANSPORT_SECRET,
624
+ ...(environment.HELYX_ECKO_TRANSPORT_PREVIOUS_SECRET
625
+ ? [environment.HELYX_ECKO_TRANSPORT_PREVIOUS_SECRET]
626
+ : []),
627
+ ]),
628
+ registry: moderationPlatformServices.scannerRegistry,
629
+ evidence: new EckoEvidenceReceiver(moderationPlatformServices.evidenceStore, environment.HELYX_DEPLOYMENT_ENVIRONMENT, Buffer.from(environment.HELYX_ECKO_TRANSPORT_SECRET, "base64")),
630
+ gateState: () => eckoOperations.readGate(),
631
+ telemetry: {
632
+ accept: (batch) => eckoOperations.acceptTelemetry(batch),
633
+ },
634
+ }
635
+ : undefined;
587
636
  controlServer = await createBotControlServer({
588
637
  service: controlService,
589
638
  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
+ },
590
646
  logger: { level: environment.LOG_LEVEL },
647
+ runtimeConfiguration: {
648
+ hostedModerationEnabled: environment.HELYX_HOSTED_MODERATION_ENABLED,
649
+ },
591
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),
651
+ ...(ecko ? { ecko } : {}),
592
652
  });
593
653
  await controlServer.listen({
594
654
  host: "::",
@@ -667,7 +727,7 @@ function deploymentReference(processEnvironment) {
667
727
  processEnvironment.GIT_COMMIT_SHA;
668
728
  return value?.trim().slice(0, 12) || undefined;
669
729
  }
670
- export function gatewayIntentsForModules(modules, messageContentEnabled = false, guildMembersEnabled = false) {
730
+ export function gatewayIntentsForModules(modules, messageContentEnabled = false, guildMembersEnabled = false, runtimeProfile = "hosted") {
671
731
  const intents = [GatewayIntentBits.Guilds];
672
732
  if (modules.some((module) => module.events?.messageReactionAdd?.length ||
673
733
  module.events?.messageReactionRemove?.length ||
@@ -675,8 +735,12 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false,
675
735
  module.events?.messageReactionRemoveEmoji?.length)) {
676
736
  intents.push(GatewayIntentBits.GuildMessageReactions);
677
737
  }
678
- const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.length);
679
- const requiresMessageContent = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
738
+ const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.some(({ featureId }) => !featureId || runtimeProfile === "self-hosted") ||
739
+ module.events?.privilegedMessageUpdate?.some(({ featureId }) => !featureId || runtimeProfile === "self-hosted"));
740
+ const requiresMessageContent = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content") ||
741
+ (runtimeProfile === "self-hosted" &&
742
+ module.manifest.manifestVersion === "0.5" &&
743
+ module.manifest.features.some((feature) => feature.requiredDiscordCapabilities.includes("message-content"))));
680
744
  if (((hasPrivilegedMessageCreate || requiresMessageContent) &&
681
745
  messageContentEnabled) ||
682
746
  modules.some((module) => module.events?.guildMessageCreate?.length ||
@@ -698,6 +762,12 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false,
698
762
  if (modules.some((module) => module.events?.guildVoiceSession?.length)) {
699
763
  intents.push(GatewayIntentBits.GuildVoiceStates);
700
764
  }
765
+ if (modules.some((module) => module.events?.discordAutoModerationRuleCreate?.length ||
766
+ module.events?.discordAutoModerationRuleUpdate?.length ||
767
+ module.events?.discordAutoModerationRuleDelete?.length))
768
+ intents.push(GatewayIntentBits.AutoModerationConfiguration);
769
+ if (modules.some((module) => module.events?.discordAutoModerationActionExecution?.length))
770
+ intents.push(GatewayIntentBits.AutoModerationExecution);
701
771
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
702
772
  module.events?.guildEmojiUpdate?.length)) {
703
773
  intents.push(GatewayIntentBits.GuildExpressions);
@@ -0,0 +1,11 @@
1
+ import type { ModerationActionRegistryService, ModerationActionResultV2, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
2
+ import type { DiscordViolationEvidenceDeliveryAdapter } from "./discord-violation-thread-service.js";
3
+ /** Local closed registry. In sharded mode it is instantiated in each guild owner. */
4
+ export declare class BotModerationActionRegistry implements ModerationActionRegistryService {
5
+ #private;
6
+ private readonly services;
7
+ private readonly evidenceDelivery?;
8
+ constructor(modules: readonly ModuleDefinition[], services: ServiceAccess, evidenceDelivery?: DiscordViolationEvidenceDeliveryAdapter | undefined);
9
+ execute(input: Parameters<ModerationActionRegistryService["execute"]>[0]): Promise<ModerationActionResultV2>;
10
+ }
11
+ //# sourceMappingURL=moderation-action-registry.d.ts.map
@@ -0,0 +1,375 @@
1
+ import { HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE } from "@helyx/module-manifest";
2
+ import { AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION, AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID, AUTOMOD_TIMEOUT_SECONDS, HELYX_SERVICE_NAMES, MODERATION_ACTION_CONTRACT_VERSION, MODERATION_ACTION_PROVIDER_ID, MODERATION_CONTRACT_LIMITS, } from "@helyx/sdk";
3
+ const MODERATION_MODULE_ID = "helyx.moderation";
4
+ const AUTOMOD_MODULE_ID = "helyx.automod";
5
+ const ALLOWED_CALLER_MODULE_IDS = new Set([AUTOMOD_MODULE_ID]);
6
+ const ID_PATTERN = /^\d{17,20}$/u;
7
+ const OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/u;
8
+ const SAFE_CODE_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/u;
9
+ /** Local closed registry. In sharded mode it is instantiated in each guild owner. */
10
+ export class BotModerationActionRegistry {
11
+ services;
12
+ evidenceDelivery;
13
+ #actionProvider;
14
+ #policyProvider;
15
+ constructor(modules, services, evidenceDelivery) {
16
+ this.services = services;
17
+ this.evidenceDelivery = evidenceDelivery;
18
+ const actionProviders = modules.flatMap((module) => module.moderationActionProvider
19
+ ? [
20
+ {
21
+ moduleId: module.manifest.id,
22
+ provider: module.moderationActionProvider,
23
+ },
24
+ ]
25
+ : []);
26
+ if (actionProviders.length > 1)
27
+ throw new Error("Moderation action provider ownership is ambiguous");
28
+ const actionRegistration = actionProviders[0];
29
+ if (!actionRegistration) {
30
+ this.#actionProvider = null;
31
+ }
32
+ else {
33
+ if (actionRegistration.moduleId !== MODERATION_MODULE_ID ||
34
+ actionRegistration.provider.providerId !==
35
+ MODERATION_ACTION_PROVIDER_ID ||
36
+ actionRegistration.provider.contractVersion !==
37
+ MODERATION_ACTION_CONTRACT_VERSION ||
38
+ actionRegistration.provider.allowedCallerModuleIds.length < 1 ||
39
+ new Set(actionRegistration.provider.allowedCallerModuleIds).size !==
40
+ actionRegistration.provider.allowedCallerModuleIds.length ||
41
+ actionRegistration.provider.allowedCallerModuleIds.some((moduleId) => !ALLOWED_CALLER_MODULE_IDS.has(moduleId)))
42
+ throw new Error("Moderation action provider registration is invalid");
43
+ this.#actionProvider = actionRegistration.provider;
44
+ }
45
+ const policyProviders = modules.flatMap((module) => module.autoModerationEnforcementPolicyProvider
46
+ ? [
47
+ {
48
+ moduleId: module.manifest.id,
49
+ provider: module.autoModerationEnforcementPolicyProvider,
50
+ },
51
+ ]
52
+ : []);
53
+ if (policyProviders.length > 1)
54
+ throw new Error("Auto Moderation policy provider ownership is ambiguous");
55
+ const policyRegistration = policyProviders[0];
56
+ if (!policyRegistration) {
57
+ this.#policyProvider = null;
58
+ }
59
+ else {
60
+ if (policyRegistration.moduleId !== AUTOMOD_MODULE_ID ||
61
+ policyRegistration.provider.providerId !==
62
+ AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID ||
63
+ policyRegistration.provider.contractVersion !==
64
+ AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION)
65
+ throw new Error("Auto Moderation policy provider registration is invalid");
66
+ this.#policyProvider = policyRegistration.provider;
67
+ }
68
+ }
69
+ async execute(input) {
70
+ if (!this.#actionProvider ||
71
+ !this.#policyProvider ||
72
+ input.providerId !== MODERATION_ACTION_PROVIDER_ID ||
73
+ input.contractVersion !== MODERATION_ACTION_CONTRACT_VERSION)
74
+ return rejected("module_unavailable");
75
+ if (input.callerModuleId !== input.request.callerModuleId ||
76
+ !ALLOWED_CALLER_MODULE_IDS.has(input.callerModuleId) ||
77
+ !this.#actionProvider.allowedCallerModuleIds.includes(input.callerModuleId))
78
+ return rejected("caller_not_allowed");
79
+ if (!isValidRequest(input.request))
80
+ return rejected("invalid_request");
81
+ const installations = this.services.get(HELYX_SERVICE_NAMES.installations);
82
+ const availability = this.services.has(HELYX_SERVICE_NAMES.moduleAvailability)
83
+ ? this.services.get(HELYX_SERVICE_NAMES.moduleAvailability)
84
+ : null;
85
+ try {
86
+ const modulesAvailable = (await installations.isModuleEnabled(input.request.guildId, MODERATION_MODULE_ID)) &&
87
+ (await installations.isModuleEnabled(input.request.guildId, input.callerModuleId)) &&
88
+ (!availability ||
89
+ ((await availability.isAvailable(MODERATION_MODULE_ID)) &&
90
+ (await availability.isAvailable(input.callerModuleId))));
91
+ if (!modulesAvailable)
92
+ return rejected("module_unavailable");
93
+ if (input.request.detectionSource === "enhanced" &&
94
+ !(await enhancedFeatureAllowed(this.services, input.request.guildId)))
95
+ return rejected("feature_unavailable");
96
+ const policyContext = { services: this.services };
97
+ const resolution = await this.#policyProvider.resolve(policyContext, input.request);
98
+ if (resolution.outcome === "rejected")
99
+ return rejected(mapPolicyRejection(resolution.code));
100
+ if (!isValidPolicy(resolution))
101
+ return rejected("invalid_request");
102
+ if (resolution.configurationRevision !== input.request.configurationRevision)
103
+ return rejected("stale_revision");
104
+ const policy = copyPolicy(resolution);
105
+ const evidenceClaims = {
106
+ caseIds: new Set(),
107
+ threadIds: new Set(),
108
+ };
109
+ const requestServices = createRequestBoundServices(this.services, evidenceClaims);
110
+ return await this.#actionProvider.execute({
111
+ services: requestServices,
112
+ violationEvidence: {
113
+ deliver: async (delivery) => {
114
+ if (!input.request.evidenceReference ||
115
+ delivery.guildId !== input.request.guildId ||
116
+ !ID_PATTERN.test(delivery.threadId) ||
117
+ !OPERATION_KEY_PATTERN.test(delivery.caseId) ||
118
+ !OPERATION_KEY_PATTERN.test(delivery.operationKey) ||
119
+ !evidenceClaims.caseIds.has(delivery.caseId) ||
120
+ !evidenceClaims.threadIds.has(delivery.threadId))
121
+ return { outcome: "evidence_unavailable" };
122
+ if (!this.evidenceDelivery)
123
+ return { outcome: "evidence_unavailable" };
124
+ return await this.evidenceDelivery.deliverEvidence({
125
+ guildId: delivery.guildId,
126
+ threadId: delivery.threadId,
127
+ evidenceReference: input.request.evidenceReference,
128
+ operationKey: delivery.operationKey,
129
+ });
130
+ },
131
+ },
132
+ }, {
133
+ ...input.request,
134
+ policy,
135
+ revalidatePolicy: async () => {
136
+ try {
137
+ const current = await this.#policyProvider?.resolve(policyContext, input.request);
138
+ return Boolean(current &&
139
+ current.outcome === "eligible" &&
140
+ isValidPolicy(current) &&
141
+ samePolicy(policy, current) &&
142
+ (input.request.detectionSource !== "enhanced" ||
143
+ (await enhancedFeatureAllowed(this.services, input.request.guildId))));
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ },
149
+ });
150
+ }
151
+ catch {
152
+ return rejected("module_unavailable");
153
+ }
154
+ }
155
+ }
156
+ function isValidRequest(request) {
157
+ return (OPERATION_KEY_PATTERN.test(request.operationKey) &&
158
+ (request.detectionSource === "native" ||
159
+ request.detectionSource === "enhanced") &&
160
+ ID_PATTERN.test(request.guildId) &&
161
+ ID_PATTERN.test(request.subjectUserId) &&
162
+ OPERATION_KEY_PATTERN.test(request.sourceEventId) &&
163
+ request.occurredAt instanceof Date &&
164
+ !Number.isNaN(request.occurredAt.getTime()) &&
165
+ OPERATION_KEY_PATTERN.test(request.ruleId) &&
166
+ request.configurationRevision.length > 0 &&
167
+ request.configurationRevision.length <= 200 &&
168
+ SAFE_CODE_PATTERN.test(request.reasonCode) &&
169
+ ["low", "medium", "high", "critical"].includes(request.severity) &&
170
+ ["exact", "native_equivalent", "high", "medium", "low"].includes(request.confidenceClass) &&
171
+ (request.sourceMessage === null ||
172
+ (ID_PATTERN.test(request.sourceMessage.channelId) &&
173
+ (request.sourceMessage.messageId === null ||
174
+ ID_PATTERN.test(request.sourceMessage.messageId)))) &&
175
+ isValidEvidenceReference(request) &&
176
+ ((request.requestKind === "detection" &&
177
+ request.observedAction === undefined) ||
178
+ (request.requestKind === "observation" &&
179
+ isValidObservedAction(request.observedAction))));
180
+ }
181
+ function isValidObservedAction(action) {
182
+ if (!action || typeof action !== "object" || !("type" in action))
183
+ return false;
184
+ return (action.type === "discord_native_block_message" ||
185
+ (action.type === "discord_native_timeout" &&
186
+ AUTOMOD_TIMEOUT_SECONDS.includes(action.durationSeconds)));
187
+ }
188
+ /**
189
+ * The Moderation provider gets request-scoped views of the two services that
190
+ * establish the case and thread. Evidence delivery is then limited to IDs
191
+ * returned by those services during this exact action invocation.
192
+ */
193
+ function createRequestBoundServices(services, claims) {
194
+ return {
195
+ has: (name) => services.has(name),
196
+ get: (name) => {
197
+ if (name === HELYX_SERVICE_NAMES.atomicModerationCases) {
198
+ const service = services.get(name);
199
+ const bound = {
200
+ createPendingCase: async (input) => {
201
+ const result = await service.createPendingCase(input);
202
+ if (result.outcome === "created" || result.outcome === "replayed")
203
+ claims.caseIds.add(result.caseId);
204
+ return result;
205
+ },
206
+ createConfirmedAutoModerationOffenceCase: async (input) => {
207
+ const result = await service.createConfirmedAutoModerationOffenceCase(input);
208
+ if (result.outcome === "created" || result.outcome === "replayed")
209
+ claims.caseIds.add(result.caseId);
210
+ return result;
211
+ },
212
+ createAdditionalPendingAttempt: (input) => service.createAdditionalPendingAttempt(input),
213
+ createCorrectionCase: (input) => service.createCorrectionCase(input),
214
+ settleActionAttempt: (input) => service.settleActionAttempt(input),
215
+ recordObservedAction: (input) => service.recordObservedAction(input),
216
+ listReconciliationCandidates: (input) => service.listReconciliationCandidates(input),
217
+ disassociateCase: (input) => service.disassociateCase(input),
218
+ executeRetention: (input) => service.executeRetention(input),
219
+ };
220
+ return bound;
221
+ }
222
+ if (name === HELYX_SERVICE_NAMES.violationThreads) {
223
+ const service = services.get(name);
224
+ const bound = {
225
+ inspectConfiguration: (input) => service.inspectConfiguration(input),
226
+ createAccessRole: (input) => service.createAccessRole(input),
227
+ configureParent: (input) => service.configureParent(input),
228
+ create: async (input) => {
229
+ const result = await service.create(input);
230
+ if (result.outcome === "created" ||
231
+ result.outcome === "already_exists")
232
+ claims.threadIds.add(result.threadId);
233
+ return result;
234
+ },
235
+ addMember: (input) => service.addMember(input),
236
+ removeMember: (input) => service.removeMember(input),
237
+ send: (input) => service.send(input),
238
+ close: (input) => service.close(input),
239
+ reconcile: (input) => service.reconcile(input),
240
+ };
241
+ return bound;
242
+ }
243
+ return services.get(name);
244
+ },
245
+ };
246
+ }
247
+ function isValidPolicy(policy) {
248
+ return (policy.policyFingerprint.length > 0 &&
249
+ policy.policyFingerprint.length <= 200 &&
250
+ Number.isSafeInteger(policy.ruleRevision) &&
251
+ policy.ruleRevision > 0 &&
252
+ policy.configurationRevision.length > 0 &&
253
+ policy.configurationRevision.length <= 200 &&
254
+ policy.windowStartsAt instanceof Date &&
255
+ !Number.isNaN(policy.windowStartsAt.getTime()) &&
256
+ typeof policy.openViolationThread === "boolean" &&
257
+ validRoleIds(policy.globalIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
258
+ validRoleIds(policy.ruleIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
259
+ Boolean(policy.actionPolicy) &&
260
+ isValidAction(policy.actionPolicy.first) &&
261
+ isValidAction(policy.actionPolicy.second) &&
262
+ isValidAction(policy.actionPolicy.thirdAndLater));
263
+ }
264
+ function isValidAction(action) {
265
+ if (!action || typeof action !== "object" || !("type" in action))
266
+ return false;
267
+ switch (action.type) {
268
+ case "case_only":
269
+ case "delete_message":
270
+ case "warn":
271
+ case "kick":
272
+ return true;
273
+ case "timeout":
274
+ return AUTOMOD_TIMEOUT_SECONDS.includes(action.durationSeconds);
275
+ case "ban":
276
+ return [0, 3_600, 21_600, 86_400, 259_200, 604_800].includes(action.deleteMessageSeconds);
277
+ case "demote":
278
+ return (action.roleIds.length > 0 &&
279
+ validRoleIds(action.roleIds, MODERATION_CONTRACT_LIMITS.automaticDemotionRoles));
280
+ }
281
+ }
282
+ function isValidEvidenceReference(request) {
283
+ const reference = request.evidenceReference;
284
+ if (!reference)
285
+ return true;
286
+ if (reference.key.trim().length < 1 ||
287
+ reference.key.length > 160 ||
288
+ reference.sourceEventId !== request.sourceEventId ||
289
+ reference.guildId !== request.guildId ||
290
+ reference.authorUserId !== request.subjectUserId ||
291
+ request.sourceMessage === null ||
292
+ reference.channelId !== request.sourceMessage.channelId ||
293
+ reference.messageId !== request.sourceMessage.messageId)
294
+ return false;
295
+ return (reference.source === "discord_auto_moderation" ||
296
+ (reference.source === "enhanced_scanner" &&
297
+ reference.configurationRevision === request.configurationRevision));
298
+ }
299
+ function validRoleIds(roleIds, maximum) {
300
+ return (roleIds.length <= maximum &&
301
+ new Set(roleIds).size === roleIds.length &&
302
+ roleIds.every((roleId) => ID_PATTERN.test(roleId)));
303
+ }
304
+ function copyPolicy(policy) {
305
+ return {
306
+ ...policy,
307
+ windowStartsAt: new Date(policy.windowStartsAt),
308
+ globalIgnoredRoleIds: Object.freeze([...policy.globalIgnoredRoleIds]),
309
+ ruleIgnoredRoleIds: Object.freeze([...policy.ruleIgnoredRoleIds]),
310
+ actionPolicy: Object.freeze({
311
+ first: copyAction(policy.actionPolicy.first),
312
+ second: copyAction(policy.actionPolicy.second),
313
+ thirdAndLater: copyAction(policy.actionPolicy.thirdAndLater),
314
+ }),
315
+ };
316
+ }
317
+ function copyAction(action) {
318
+ return action.type === "demote"
319
+ ? { ...action, roleIds: Object.freeze([...action.roleIds]) }
320
+ : { ...action };
321
+ }
322
+ function samePolicy(expected, current) {
323
+ return (expected.policyFingerprint === current.policyFingerprint &&
324
+ expected.ruleRevision === current.ruleRevision &&
325
+ expected.configurationRevision === current.configurationRevision &&
326
+ expected.windowStartsAt.getTime() === current.windowStartsAt.getTime() &&
327
+ expected.openViolationThread === current.openViolationThread &&
328
+ sameStrings(expected.globalIgnoredRoleIds, current.globalIgnoredRoleIds) &&
329
+ sameStrings(expected.ruleIgnoredRoleIds, current.ruleIgnoredRoleIds) &&
330
+ sameAction(expected.actionPolicy.first, current.actionPolicy.first) &&
331
+ sameAction(expected.actionPolicy.second, current.actionPolicy.second) &&
332
+ sameAction(expected.actionPolicy.thirdAndLater, current.actionPolicy.thirdAndLater));
333
+ }
334
+ function sameAction(expected, current) {
335
+ if (expected.type !== current.type)
336
+ return false;
337
+ if (expected.type === "timeout" && current.type === "timeout")
338
+ return expected.durationSeconds === current.durationSeconds;
339
+ if (expected.type === "ban" && current.type === "ban")
340
+ return expected.deleteMessageSeconds === current.deleteMessageSeconds;
341
+ if (expected.type === "demote" && current.type === "demote")
342
+ return sameStrings(expected.roleIds, current.roleIds);
343
+ return true;
344
+ }
345
+ function sameStrings(expected, current) {
346
+ return (expected.length === current.length &&
347
+ expected.every((value, index) => value === current[index]));
348
+ }
349
+ function mapPolicyRejection(code) {
350
+ switch (code) {
351
+ case "source_unresolved":
352
+ return "invalid_request";
353
+ case "source_exempt":
354
+ return "subject_exempt";
355
+ default:
356
+ return code;
357
+ }
358
+ }
359
+ async function enhancedFeatureAllowed(services, guildId) {
360
+ if (!services.has(HELYX_SERVICE_NAMES.featureAccess))
361
+ return false;
362
+ const access = await services
363
+ .get(HELYX_SERVICE_NAMES.featureAccess)
364
+ .check({
365
+ guildId,
366
+ moduleId: AUTOMOD_MODULE_ID,
367
+ featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
368
+ })
369
+ .catch(() => ({ decision: "dependency_unhealthy" }));
370
+ return access.decision === "granted";
371
+ }
372
+ function rejected(code) {
373
+ return { outcome: "rejected", code };
374
+ }
375
+ //# sourceMappingURL=moderation-action-registry.js.map
@@ -0,0 +1,13 @@
1
+ import { type AtomicModerationCaseService, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
2
+ /** Adds Auto Moderation unlinking after the authoritative case transition. */
3
+ export declare function decorateModerationCaseDisassociation(input: {
4
+ modules: readonly ModuleDefinition[];
5
+ services: ServiceAccess;
6
+ service: AtomicModerationCaseService;
7
+ }): AtomicModerationCaseService;
8
+ export declare function installModerationCaseDisassociation(input: {
9
+ modules: readonly ModuleDefinition[];
10
+ services: Map<string, unknown>;
11
+ serviceAccess: ServiceAccess;
12
+ }): void;
13
+ //# sourceMappingURL=moderation-case-disassociation.d.ts.map