@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
@@ -0,0 +1,100 @@
1
+ import { type DatabasePool, type HostedServerLeaveDeliveryOutcome } from "@helyx/database";
2
+ import type { Client, Guild } from "discord.js";
3
+ import { z } from "zod";
4
+ import type { InteractionLogger } from "./interactions.js";
5
+ declare const resultSchema: z.ZodObject<{
6
+ operationId: z.ZodUUID;
7
+ guildId: z.ZodString;
8
+ action: z.ZodEnum<{
9
+ leave: "leave";
10
+ blacklist: "blacklist";
11
+ }>;
12
+ outcome: z.ZodEnum<{
13
+ completed: "completed";
14
+ requires_review: "requires_review";
15
+ failed: "failed";
16
+ queued: "queued";
17
+ not_currently_installed: "not_currently_installed";
18
+ accepted_for_retry: "accepted_for_retry";
19
+ already_blacklisted: "already_blacklisted";
20
+ }>;
21
+ paidSlotsReleased: z.ZodNumber;
22
+ }, z.core.$loose>;
23
+ export interface DeveloperServerModeration {
24
+ moderate(input: {
25
+ action: "leave" | "blacklist";
26
+ guildId: string;
27
+ actorUserId: string;
28
+ reason: string;
29
+ idempotencyKey: string;
30
+ }): Promise<{
31
+ operationId: string;
32
+ outcome: z.infer<typeof resultSchema>["outcome"];
33
+ paidSlotsReleased: number;
34
+ }>;
35
+ }
36
+ export declare class HostedServerModerationClient implements DeveloperServerModeration {
37
+ private readonly apiUrl;
38
+ private readonly secret;
39
+ constructor(apiUrl: string, secret: string);
40
+ moderate(input: {
41
+ action: "leave" | "blacklist";
42
+ guildId: string;
43
+ actorUserId: string;
44
+ reason: string;
45
+ idempotencyKey: string;
46
+ }): Promise<{
47
+ operationId: string;
48
+ outcome: "completed" | "requires_review" | "failed" | "queued" | "not_currently_installed" | "accepted_for_retry" | "already_blacklisted";
49
+ paidSlotsReleased: number;
50
+ }>;
51
+ }
52
+ export interface HostedBotInviteGate {
53
+ isEnabled(): Promise<boolean>;
54
+ }
55
+ export declare class HostedBotInviteGateClient implements HostedBotInviteGate {
56
+ private readonly apiUrl;
57
+ private readonly secret;
58
+ constructor(apiUrl: string, secret: string);
59
+ isEnabled(): Promise<boolean>;
60
+ }
61
+ export interface HostedGuildOwnerObserver {
62
+ observeOwner(guild: Guild): Promise<void>;
63
+ }
64
+ export interface HostedGuildSafety extends HostedGuildOwnerObserver {
65
+ isBlacklisted(guildId: string): Promise<boolean>;
66
+ blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
67
+ recordAutomaticLeave(guildId: string): Promise<string>;
68
+ }
69
+ export interface HostedServerOwnerObservation {
70
+ username: string;
71
+ observedAt: string;
72
+ }
73
+ export declare class PostgresHostedGuildSafety implements HostedGuildSafety {
74
+ #private;
75
+ constructor(database: DatabasePool);
76
+ isBlacklisted(guildId: string): Promise<boolean>;
77
+ blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
78
+ recordAutomaticLeave(guildId: string): Promise<string>;
79
+ observeOwner(guild: Guild): Promise<void>;
80
+ refreshOwner(guild: Guild): Promise<HostedServerOwnerObservation>;
81
+ }
82
+ export declare class HostedServerLeaveWorker {
83
+ #private;
84
+ private readonly client;
85
+ private readonly logger;
86
+ private readonly shardScope?;
87
+ constructor(database: DatabasePool, client: Client, logger: InteractionLogger, shardScope?: {
88
+ shardId: number;
89
+ shardCount: number;
90
+ } | undefined);
91
+ start(): void;
92
+ runOnce(): Promise<void>;
93
+ deliverOperation(input: {
94
+ operationId: string;
95
+ guildId: string;
96
+ }): Promise<HostedServerLeaveDeliveryOutcome>;
97
+ stop(): Promise<void>;
98
+ }
99
+ export {};
100
+ //# sourceMappingURL=hosted-server-moderation.d.ts.map
@@ -0,0 +1,226 @@
1
+ import { HostedServerSafetyRepository, } from "@helyx/database";
2
+ import { z } from "zod";
3
+ const resultSchema = z
4
+ .object({
5
+ operationId: z.uuid(),
6
+ guildId: z.string().regex(/^\d{17,20}$/u),
7
+ action: z.enum(["leave", "blacklist"]),
8
+ outcome: z.enum([
9
+ "queued",
10
+ "completed",
11
+ "already_blacklisted",
12
+ "not_currently_installed",
13
+ "accepted_for_retry",
14
+ "requires_review",
15
+ "failed",
16
+ ]),
17
+ paidSlotsReleased: z.number().int().nonnegative(),
18
+ })
19
+ .passthrough();
20
+ const botInviteGateSchema = z.object({ enabled: z.boolean() }).strict();
21
+ export class HostedServerModerationClient {
22
+ apiUrl;
23
+ secret;
24
+ constructor(apiUrl, secret) {
25
+ this.apiUrl = apiUrl;
26
+ this.secret = secret;
27
+ }
28
+ async moderate(input) {
29
+ const response = await fetch(new URL(`/v1/internal/hosted-server-moderation/${input.action}`, this.apiUrl), {
30
+ method: "POST",
31
+ headers: {
32
+ authorization: `Bearer ${this.secret}`,
33
+ "content-type": "application/json",
34
+ },
35
+ body: JSON.stringify({
36
+ guildId: input.guildId,
37
+ actorUserId: input.actorUserId,
38
+ reason: input.reason,
39
+ idempotencyKey: input.idempotencyKey,
40
+ }),
41
+ signal: AbortSignal.timeout(10_000),
42
+ });
43
+ if (!response.ok)
44
+ throw new Error(`Hosted moderation request failed with ${response.status}`);
45
+ const result = resultSchema.parse(await response.json());
46
+ if (result.guildId !== input.guildId || result.action !== input.action)
47
+ throw new Error("Hosted moderation response did not match the request");
48
+ return {
49
+ operationId: result.operationId,
50
+ outcome: result.outcome,
51
+ paidSlotsReleased: result.paidSlotsReleased,
52
+ };
53
+ }
54
+ }
55
+ export class HostedBotInviteGateClient {
56
+ apiUrl;
57
+ secret;
58
+ constructor(apiUrl, secret) {
59
+ this.apiUrl = apiUrl;
60
+ this.secret = secret;
61
+ }
62
+ async isEnabled() {
63
+ const response = await fetch(new URL("/v1/internal/incident-gates/bot-invites", this.apiUrl), {
64
+ headers: { authorization: `Bearer ${this.secret}` },
65
+ signal: AbortSignal.timeout(3_000),
66
+ });
67
+ if (!response.ok)
68
+ throw new Error(`Bot Invite gate request failed with ${response.status}`);
69
+ return botInviteGateSchema.parse(await response.json()).enabled;
70
+ }
71
+ }
72
+ export class PostgresHostedGuildSafety {
73
+ #repository;
74
+ constructor(database) {
75
+ this.#repository = new HostedServerSafetyRepository(database);
76
+ }
77
+ async isBlacklisted(guildId) {
78
+ return (await this.#repository.getState(guildId)).blacklisted;
79
+ }
80
+ blacklistedGuildIds(guildIds) {
81
+ return this.#repository.getBlacklistedGuildIds(guildIds);
82
+ }
83
+ recordAutomaticLeave(guildId) {
84
+ return this.#repository.enqueueAutomaticLeave(guildId);
85
+ }
86
+ async observeOwner(guild) {
87
+ await this.refreshOwner(guild);
88
+ }
89
+ async refreshOwner(guild) {
90
+ const cached = guild.members.cache.get(guild.ownerId)?.user ??
91
+ guild.client.users.cache?.get(guild.ownerId);
92
+ const user = cached ?? (await guild.client.users.fetch(guild.ownerId));
93
+ const observedAt = new Date();
94
+ const persisted = await this.#repository.observeOwner({
95
+ guildId: guild.id,
96
+ ownerUserId: guild.ownerId,
97
+ username: user.username,
98
+ observedAt,
99
+ });
100
+ if (!persisted)
101
+ throw new Error("Guild owner observation no longer matched the installation");
102
+ return { username: user.username, observedAt: observedAt.toISOString() };
103
+ }
104
+ }
105
+ export class HostedServerLeaveWorker {
106
+ client;
107
+ logger;
108
+ shardScope;
109
+ #repository;
110
+ #timer;
111
+ #active;
112
+ #stopped = false;
113
+ constructor(database, client, logger, shardScope) {
114
+ this.client = client;
115
+ this.logger = logger;
116
+ this.shardScope = shardScope;
117
+ this.#repository = new HostedServerSafetyRepository(database);
118
+ }
119
+ start() {
120
+ if (this.#timer || this.#stopped)
121
+ return;
122
+ this.#runSoon();
123
+ this.#timer = setInterval(() => this.#runSoon(), 15_000);
124
+ this.#timer.unref();
125
+ }
126
+ async runOnce() {
127
+ if (this.#stopped || this.#active)
128
+ return this.#active;
129
+ this.#active = this.#drain().finally(() => {
130
+ this.#active = undefined;
131
+ });
132
+ return this.#active;
133
+ }
134
+ async deliverOperation(input) {
135
+ if (this.#stopped)
136
+ return "queued";
137
+ await this.#active;
138
+ const claim = await this.#repository.claimLeaveDelivery({
139
+ ...input,
140
+ leaseSeconds: 30,
141
+ ...(this.shardScope ?? {}),
142
+ });
143
+ if (claim)
144
+ return this.#deliver(claim);
145
+ return ((await this.#repository.getLeaveDeliveryOutcome(input.operationId, input.guildId)) ?? "queued");
146
+ }
147
+ async stop() {
148
+ this.#stopped = true;
149
+ if (this.#timer)
150
+ clearInterval(this.#timer);
151
+ this.#timer = undefined;
152
+ await this.#active;
153
+ }
154
+ async #drain() {
155
+ const claims = await this.#repository.claimLeaveDeliveries({
156
+ limit: 10,
157
+ leaseSeconds: 30,
158
+ ...(this.shardScope ?? {}),
159
+ });
160
+ await Promise.all(claims.map((claim) => this.#deliver(claim)));
161
+ }
162
+ #runSoon() {
163
+ void this.runOnce().catch(() => {
164
+ this.logger.error({
165
+ event: "hosted_moderation.leave_claim_failed",
166
+ reasonCode: "database_unavailable",
167
+ }, "Hosted server leave claims are temporarily unavailable");
168
+ });
169
+ }
170
+ async #deliver(claim) {
171
+ const guild = this.client.guilds.cache.get(claim.guildId);
172
+ if (!guild) {
173
+ await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
174
+ return "not_currently_installed";
175
+ }
176
+ try {
177
+ await guild.leave();
178
+ await this.#repository.completeLeaveDelivery(claim, "completed");
179
+ this.logger.info({
180
+ event: "hosted_moderation.leave_completed",
181
+ guildId: claim.guildId,
182
+ operationId: claim.operationId,
183
+ }, "Hosted server leave completed");
184
+ return "completed";
185
+ }
186
+ catch (error) {
187
+ const failureCode = classifyLeaveFailure(error);
188
+ if (failureCode === "discord_not_installed") {
189
+ await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
190
+ return "not_currently_installed";
191
+ }
192
+ const outcome = await this.#repository.failLeaveDelivery({
193
+ claim,
194
+ failureCode,
195
+ retryAfterSeconds: Math.min(3_600, 15 * 2 ** claim.attemptCount),
196
+ });
197
+ this.logger.warn({
198
+ event: "hosted_moderation.leave_deferred",
199
+ guildId: claim.guildId,
200
+ operationId: claim.operationId,
201
+ failureCode,
202
+ outcome,
203
+ }, "Hosted server leave will be retried or reviewed");
204
+ return outcome === "requires_review"
205
+ ? outcome
206
+ : outcome === "retry"
207
+ ? "accepted_for_retry"
208
+ : "queued";
209
+ }
210
+ }
211
+ }
212
+ function classifyLeaveFailure(error) {
213
+ if (typeof error !== "object" || error === null)
214
+ return "discord_transient";
215
+ const candidate = error;
216
+ const code = candidate.code;
217
+ if (code === 10_004 || code === 50_001)
218
+ return "discord_not_installed";
219
+ const status = candidate.status;
220
+ if (status === 401 || status === 403)
221
+ return "discord_authentication";
222
+ if (status === 429)
223
+ return "discord_rate_limited";
224
+ return "discord_transient";
225
+ }
226
+ //# sourceMappingURL=hosted-server-moderation.js.map
package/dist/index.d.ts CHANGED
@@ -11,5 +11,5 @@ export interface BotHandle {
11
11
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
12
12
  export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
13
13
  export declare function readBotVersion(): string;
14
- export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean, guildMembersEnabled?: boolean): GatewayIntentBits[];
14
+ export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean, guildMembersEnabled?: boolean, runtimeProfile?: "hosted" | "self-hosted"): GatewayIntentBits[];
15
15
  //# sourceMappingURL=index.d.ts.map
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, 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";
@@ -50,6 +54,7 @@ import { discordRestOptions } from "./discord-rest.js";
50
54
  import { discordBotStatusService } from "./status-producer.js";
51
55
  import { assertReviewedPremiumCatalogue, requirePremiumProviderEnvironment, } from "./premium-access.js";
52
56
  import { enforcePremiumCatalogue, PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS, } from "./premium-catalogue-enforcement.js";
57
+ import { HostedBotInviteGateClient, HostedServerLeaveWorker, HostedServerModerationClient, PostgresHostedGuildSafety, } from "./hosted-server-moderation.js";
53
58
  export { loadConfiguredModulePackages } from "./modules.js";
54
59
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
55
60
  export * from "./control-server.js";
@@ -119,6 +124,17 @@ async function startUnshardedBot(processEnvironment) {
119
124
  sslMode: environment.DATABASE_SSL,
120
125
  applicationName: "helyx-bot",
121
126
  });
127
+ const hostedOwnerObserver = new PostgresHostedGuildSafety(database);
128
+ const eckoOperations = new EckoOperationsRepository(database);
129
+ const hostedSafety = environment.HELYX_HOSTED_MODERATION_ENABLED
130
+ ? hostedOwnerObserver
131
+ : undefined;
132
+ const developerModeration = environment.HELYX_HOSTED_MODERATION_ENABLED
133
+ ? new HostedServerModerationClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
134
+ : undefined;
135
+ const hostedInviteGate = environment.HELYX_HOSTED_MODERATION_ENABLED
136
+ ? new HostedBotInviteGateClient(environment.HELYX_CLOUD_API_URL, environment.HELYX_BOT_CONTROL_SECRET)
137
+ : undefined;
122
138
  const privacyDatabase = environment.HELYX_PRIVACY_ENABLED &&
123
139
  environment.HELYX_PRIVACY_EXECUTOR_DATABASE_URL &&
124
140
  environment.HELYX_PRIVACY_EXECUTOR_DATABASE_SSL
@@ -153,11 +169,12 @@ async function startUnshardedBot(processEnvironment) {
153
169
  verifiedMigrationCount: migrationResult.verified.length,
154
170
  }, "Core database ready");
155
171
  const cleanupExpiredCoreRecords = async () => {
156
- const [sessions, rateLimits, verificationAudit, commandRecords] = await Promise.all([
172
+ const [sessions, rateLimits, verificationAudit, commandRecords, hostedLeaveDeliveries,] = await Promise.all([
157
173
  new InteractionSessionRepository(database).deleteExpired(),
158
174
  new RateLimitRepository(database).deleteExpired(),
159
175
  new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
160
176
  new GuildCommandRepository(database).deleteExpired(12),
177
+ new HostedServerSafetyRepository(database).deleteTerminalLeaveDeliveriesOlderThanMonths(24),
161
178
  ]);
162
179
  const commandRecordCount = commandRecords.history +
163
180
  commandRecords.idempotencyKeys +
@@ -166,13 +183,15 @@ async function startUnshardedBot(processEnvironment) {
166
183
  if (sessions > 0 ||
167
184
  rateLimits > 0 ||
168
185
  verificationAudit > 0 ||
169
- commandRecordCount > 0) {
186
+ commandRecordCount > 0 ||
187
+ hostedLeaveDeliveries > 0) {
170
188
  bootstrapLogger.info({
171
189
  event: "database.retention_cleaned",
172
190
  interactionSessions: sessions,
173
191
  rateLimitWindows: rateLimits,
174
192
  verificationAuditEvents: verificationAudit,
175
193
  commandRecords: commandRecordCount,
194
+ hostedLeaveDeliveries,
176
195
  }, "Expired core records removed");
177
196
  }
178
197
  };
@@ -213,7 +232,7 @@ async function startUnshardedBot(processEnvironment) {
213
232
  premiumCatalogueTimer.unref();
214
233
  const client = new Client({
215
234
  rest: discordRestOptions,
216
- 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),
217
236
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
218
237
  allowedMentions: { parse: [] },
219
238
  });
@@ -339,6 +358,7 @@ async function startUnshardedBot(processEnvironment) {
339
358
  health: {
340
359
  getHealth: buildHealth,
341
360
  },
361
+ ...(developerModeration ? { moderation: developerModeration } : {}),
342
362
  })
343
363
  : undefined;
344
364
  const modules = [
@@ -376,11 +396,26 @@ async function startUnshardedBot(processEnvironment) {
376
396
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
377
397
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
378
398
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
379
- platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
399
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordModerationMemberEnforcementAdapter(client));
380
400
  const memberRoles = new DiscordMemberRoleService(client);
381
401
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
382
402
  platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
383
403
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
404
+ const moderationPlatformServices = 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
+ });
384
419
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
385
420
  platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
386
421
  if (serviceOptions.inviteFingerprintKey)
@@ -402,7 +437,8 @@ async function startUnshardedBot(processEnvironment) {
402
437
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
403
438
  ...(operationsModule ? [operationsModule.manifest.id] : []),
404
439
  ]);
405
- const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, undefined, environment.DISCORD_CLIENT_ID);
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);
406
442
  const commandsReady = installInteractions(client, modules, {
407
443
  globalCommandMode: environment.HELYX_GLOBAL_COMMAND_MODE,
408
444
  ...(environment.DISCORD_INTERNAL_GUILD_ID
@@ -419,7 +455,10 @@ async function startUnshardedBot(processEnvironment) {
419
455
  ...(operationsModule ? [operationsModule.manifest.id] : []),
420
456
  ]),
421
457
  }, bootstrapLogger, serviceAccess);
422
- 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
+ });
423
462
  const controlService = new BotControlService(database, [
424
463
  ...publicModules,
425
464
  gamingStatisticsRecordOwner,
@@ -449,6 +488,7 @@ async function startUnshardedBot(processEnvironment) {
449
488
  return;
450
489
  shuttingDown = true;
451
490
  prepareGuilds.stop();
491
+ await hostedLeaveWorker?.stop();
452
492
  process.off("SIGINT", onSigint);
453
493
  process.off("SIGTERM", onSigterm);
454
494
  if (retentionCleanupTimer)
@@ -480,6 +520,12 @@ async function startUnshardedBot(processEnvironment) {
480
520
  process.exitCode = 1;
481
521
  bootstrapLogger.error({ event: "module_events.stop_failed", error: safeError(error) }, "Module event shutdown failed");
482
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
+ });
483
529
  await runtime.stop().catch((error) => {
484
530
  process.exitCode = 1;
485
531
  bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
@@ -511,8 +557,14 @@ async function startUnshardedBot(processEnvironment) {
511
557
  process.once("SIGTERM", onSigterm);
512
558
  try {
513
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
+ });
514
565
  await client.login(environment.DISCORD_TOKEN);
515
566
  await commandsReady;
567
+ hostedLeaveWorker?.start();
516
568
  scheduledTasks = createScheduledTaskRunner({
517
569
  database,
518
570
  modules: publicModules,
@@ -565,11 +617,38 @@ async function startUnshardedBot(processEnvironment) {
565
617
  }
566
618
  await cloudConnector.start();
567
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;
568
636
  controlServer = await createBotControlServer({
569
637
  service: controlService,
570
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
+ },
571
646
  logger: { level: environment.LOG_LEVEL },
647
+ runtimeConfiguration: {
648
+ hostedModerationEnabled: environment.HELYX_HOSTED_MODERATION_ENABLED,
649
+ },
572
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 } : {}),
573
652
  });
574
653
  await controlServer.listen({
575
654
  host: "::",
@@ -648,7 +727,7 @@ function deploymentReference(processEnvironment) {
648
727
  processEnvironment.GIT_COMMIT_SHA;
649
728
  return value?.trim().slice(0, 12) || undefined;
650
729
  }
651
- export function gatewayIntentsForModules(modules, messageContentEnabled = false, guildMembersEnabled = false) {
730
+ export function gatewayIntentsForModules(modules, messageContentEnabled = false, guildMembersEnabled = false, runtimeProfile = "hosted") {
652
731
  const intents = [GatewayIntentBits.Guilds];
653
732
  if (modules.some((module) => module.events?.messageReactionAdd?.length ||
654
733
  module.events?.messageReactionRemove?.length ||
@@ -656,8 +735,12 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false,
656
735
  module.events?.messageReactionRemoveEmoji?.length)) {
657
736
  intents.push(GatewayIntentBits.GuildMessageReactions);
658
737
  }
659
- const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.length);
660
- 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"))));
661
744
  if (((hasPrivilegedMessageCreate || requiresMessageContent) &&
662
745
  messageContentEnabled) ||
663
746
  modules.some((module) => module.events?.guildMessageCreate?.length ||
@@ -679,6 +762,12 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false,
679
762
  if (modules.some((module) => module.events?.guildVoiceSession?.length)) {
680
763
  intents.push(GatewayIntentBits.GuildVoiceStates);
681
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);
682
771
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
683
772
  module.events?.guildEmojiUpdate?.length)) {
684
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