@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.
- package/CHANGELOG.md +80 -0
- package/README.md +27 -2
- package/dist/auto-moderation-scanner-registry.d.ts +27 -0
- package/dist/auto-moderation-scanner-registry.js +172 -0
- package/dist/cloud-connector.js +3 -0
- package/dist/cloud-control-dispatcher.js +32 -0
- package/dist/command-path-contract.js +1 -0
- package/dist/control-permissions.d.ts +2 -2
- package/dist/control-permissions.js +4 -2
- package/dist/control-server.d.ts +72 -16
- package/dist/control-server.js +150 -250
- package/dist/dashboard-action-control.d.ts +25 -0
- package/dist/dashboard-action-control.js +73 -0
- package/dist/dashboard-action-routes.d.ts +9 -0
- package/dist/dashboard-action-routes.js +36 -0
- package/dist/dashboard-feature-control.d.ts +37 -0
- package/dist/dashboard-feature-control.js +206 -0
- package/dist/dashboard-module-projection.d.ts +9 -0
- package/dist/dashboard-module-projection.js +15 -0
- package/dist/dashboard-preview-import-routes.d.ts +6 -0
- package/dist/dashboard-preview-import-routes.js +121 -0
- package/dist/developer-operations-module.d.ts +2 -0
- package/dist/developer-operations-module.js +136 -2
- package/dist/discord-analytics.d.ts +3 -0
- package/dist/discord-analytics.js +49 -5
- package/dist/discord-auto-moderation-service.d.ts +21 -0
- package/dist/discord-auto-moderation-service.js +425 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-member-enforcement-service.d.ts +26 -0
- package/dist/discord-member-enforcement-service.js +590 -0
- package/dist/discord-message-enforcement-service.d.ts +9 -0
- package/dist/discord-message-enforcement-service.js +102 -0
- package/dist/discord-violation-thread-service.d.ts +33 -0
- package/dist/discord-violation-thread-service.js +789 -0
- package/dist/ecko-evidence-receiver.d.ts +32 -0
- package/dist/ecko-evidence-receiver.js +173 -0
- package/dist/ecko-transport-auth.d.ts +19 -0
- package/dist/ecko-transport-auth.js +80 -0
- package/dist/ecko-transport-routes.d.ts +19 -0
- package/dist/ecko-transport-routes.js +130 -0
- package/dist/embedded-auto-moderation-pool.d.ts +46 -0
- package/dist/embedded-auto-moderation-pool.js +324 -0
- package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
- package/dist/embedded-auto-moderation-scanner.js +263 -0
- package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
- package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
- package/dist/embedded-auto-moderation-worker.d.ts +2 -0
- package/dist/embedded-auto-moderation-worker.js +13 -0
- package/dist/events.d.ts +5 -1
- package/dist/events.js +275 -9
- package/dist/guilds.d.ts +2 -1
- package/dist/guilds.js +164 -5
- package/dist/hosted-server-moderation.d.ts +100 -0
- package/dist/hosted-server-moderation.js +226 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +101 -12
- package/dist/moderation-action-registry.d.ts +11 -0
- package/dist/moderation-action-registry.js +375 -0
- package/dist/moderation-case-disassociation.d.ts +13 -0
- package/dist/moderation-case-disassociation.js +83 -0
- package/dist/moderation-platform-services.d.ts +27 -0
- package/dist/moderation-platform-services.js +47 -0
- package/dist/modules.d.ts +6 -0
- package/dist/modules.js +65 -3
- package/dist/premium-activation-module.js +2 -0
- package/dist/privileged-message-metadata-cache.d.ts +35 -0
- package/dist/privileged-message-metadata-cache.js +70 -0
- package/dist/scheduled-task-runner.js +24 -1
- package/dist/shard-contracts.d.ts +151 -1
- package/dist/shard-contracts.js +142 -1
- package/dist/shard-coordinator.d.ts +1 -1
- package/dist/shard-ecko-scanner-registry.d.ts +38 -0
- package/dist/shard-ecko-scanner-registry.js +96 -0
- package/dist/shard-router.d.ts +23 -0
- package/dist/shard-router.js +69 -5
- package/dist/shard-supervisor.d.ts +11 -0
- package/dist/shard-supervisor.js +72 -6
- package/dist/shard-worker.d.ts +16 -1
- package/dist/shard-worker.js +108 -10
- package/dist/transient-violation-evidence.d.ts +39 -0
- package/dist/transient-violation-evidence.js +132 -0
- package/documentation/README.md +2 -0
- package/documentation/examples/.env.example +13 -0
- package/documentation/guides/self-hosting.md +17 -1
- package/documentation/modules/auto-moderation.md +51 -0
- package/documentation/modules/moderation.md +42 -0
- package/documentation/reference/environment.md +11 -0
- package/package.json +17 -5
package/dist/shard-supervisor.js
CHANGED
|
@@ -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 ||
|
|
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:
|
|
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) {
|
package/dist/shard-worker.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type CommandRegistration } from "./interactions.js";
|
|
2
|
-
import
|
|
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
|
package/dist/shard-worker.js
CHANGED
|
@@ -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,
|
|
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
|
|
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:
|
|
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
|