@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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { EmbeddedWorkerRuntime, } from "./embedded-auto-moderation-worker-runtime.js";
|
|
3
|
+
const workerPort = parentPort;
|
|
4
|
+
if (!workerPort)
|
|
5
|
+
throw new Error("Embedded Auto Moderation worker requires a parent port");
|
|
6
|
+
const runtime = new EmbeddedWorkerRuntime();
|
|
7
|
+
workerPort.on("message", (request) => {
|
|
8
|
+
const response = runtime.handle(request);
|
|
9
|
+
if (response)
|
|
10
|
+
workerPort.postMessage(response);
|
|
11
|
+
});
|
|
12
|
+
workerPort.postMessage({ type: "ready" });
|
|
13
|
+
//# sourceMappingURL=embedded-auto-moderation-worker.js.map
|
package/dist/events.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, InstallationService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess, VerificationMemberService } from "@helyx/sdk";
|
|
2
2
|
import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
|
|
3
3
|
import { type InteractionLogger } from "./interactions.js";
|
|
4
|
+
import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
|
|
4
5
|
export declare function availabilityAwareInstallations(services: ServiceAccess, modulesById?: ReadonlyMap<string, ModuleDefinition>): Pick<InstallationService, "isModuleEnabled">;
|
|
5
|
-
export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess
|
|
6
|
+
export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess, options?: {
|
|
7
|
+
runtimeProfile?: "hosted" | "self-hosted";
|
|
8
|
+
evidenceStore?: TransientViolationEvidenceStore;
|
|
9
|
+
}): ModuleEventHandle;
|
|
6
10
|
export interface ModuleEventHandle {
|
|
7
11
|
stop(): Promise<void>;
|
|
8
12
|
queueDepth(): number;
|
package/dist/events.js
CHANGED
|
@@ -5,6 +5,8 @@ import { toReplyOptions } from "./interactions.js";
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
|
|
7
7
|
import { canAccessModule } from "./premium-access.js";
|
|
8
|
+
import { fromDiscordAction, toRuleSnapshot, } from "./discord-auto-moderation-service.js";
|
|
9
|
+
import { PrivilegedMessageMetadataCache } from "./privileged-message-metadata-cache.js";
|
|
8
10
|
export function availabilityAwareInstallations(services, modulesById) {
|
|
9
11
|
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
10
12
|
const candidate = typeof services.has === "function" &&
|
|
@@ -21,12 +23,65 @@ export function availabilityAwareInstallations(services, modulesById) {
|
|
|
21
23
|
},
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
async function featureContributionAvailable(services, guildId, moduleId, featureId) {
|
|
27
|
+
if (!featureId)
|
|
28
|
+
return true;
|
|
29
|
+
if (!services.has(HELYX_SERVICE_NAMES.featureAccess))
|
|
30
|
+
return false;
|
|
31
|
+
try {
|
|
32
|
+
const result = await services
|
|
33
|
+
.get(HELYX_SERVICE_NAMES.featureAccess)
|
|
34
|
+
.check({ guildId, moduleId, featureId });
|
|
35
|
+
return result.decision === "granted";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function privilegedSource(message) {
|
|
42
|
+
const author = message.author;
|
|
43
|
+
if (!author?.id)
|
|
44
|
+
return { resolved: false };
|
|
45
|
+
const channel = "channel" in message ? message.channel : undefined;
|
|
46
|
+
const channelIsThread = Boolean(channel && typeof channel.isThread === "function" && channel.isThread());
|
|
47
|
+
return {
|
|
48
|
+
resolved: true,
|
|
49
|
+
userId: author.id,
|
|
50
|
+
userIsBot: author.bot,
|
|
51
|
+
userIsSystem: Boolean(author.system || message.system),
|
|
52
|
+
webhookId: message.webhookId,
|
|
53
|
+
member: message.member
|
|
54
|
+
? {
|
|
55
|
+
present: true,
|
|
56
|
+
roleIds: [...message.member.roles.cache.keys()].sort(),
|
|
57
|
+
}
|
|
58
|
+
: { present: false },
|
|
59
|
+
channelId: message.channelId,
|
|
60
|
+
parentChannelId: channelIsThread && channel && "parentId" in channel
|
|
61
|
+
? channel.parentId
|
|
62
|
+
: null,
|
|
63
|
+
channelIsThread,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function installModuleEvents(client, modules, logger, services, options) {
|
|
67
|
+
const runtimeProfile = options?.runtimeProfile ?? "hosted";
|
|
25
68
|
const modulesById = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
26
|
-
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? [])
|
|
69
|
+
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? [])
|
|
70
|
+
.filter((contribution) => !contribution.featureId || runtimeProfile === "self-hosted")
|
|
71
|
+
.map((contribution) => ({
|
|
27
72
|
moduleId: module.manifest.id,
|
|
28
73
|
contribution,
|
|
29
74
|
})));
|
|
75
|
+
const privilegedMessageUpdateContributions = modules.flatMap((module) => (module.events?.privilegedMessageUpdate ?? [])
|
|
76
|
+
.filter((contribution) => !contribution.featureId || runtimeProfile === "self-hosted")
|
|
77
|
+
.map((contribution) => ({
|
|
78
|
+
moduleId: module.manifest.id,
|
|
79
|
+
contribution,
|
|
80
|
+
})));
|
|
81
|
+
const autoModerationRuleCreateContributions = modules.flatMap((module) => (module.events?.discordAutoModerationRuleCreate ?? []).map((contribution) => ({ moduleId: module.manifest.id, contribution })));
|
|
82
|
+
const autoModerationRuleUpdateContributions = modules.flatMap((module) => (module.events?.discordAutoModerationRuleUpdate ?? []).map((contribution) => ({ moduleId: module.manifest.id, contribution })));
|
|
83
|
+
const autoModerationRuleDeleteContributions = modules.flatMap((module) => (module.events?.discordAutoModerationRuleDelete ?? []).map((contribution) => ({ moduleId: module.manifest.id, contribution })));
|
|
84
|
+
const autoModerationActionContributions = modules.flatMap((module) => (module.events?.discordAutoModerationActionExecution ?? []).map((contribution) => ({ moduleId: module.manifest.id, contribution })));
|
|
30
85
|
const guildMessageContributions = modules.flatMap((module) => (module.events?.guildMessageCreate ?? []).map((contribution) => ({
|
|
31
86
|
moduleId: module.manifest.id,
|
|
32
87
|
classificationRequested: module.requiredDiscordCapabilities?.includes("message-content") ??
|
|
@@ -101,6 +156,11 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
101
156
|
contribution,
|
|
102
157
|
})));
|
|
103
158
|
if (privilegedMessageContributions.length === 0 &&
|
|
159
|
+
privilegedMessageUpdateContributions.length === 0 &&
|
|
160
|
+
autoModerationRuleCreateContributions.length === 0 &&
|
|
161
|
+
autoModerationRuleUpdateContributions.length === 0 &&
|
|
162
|
+
autoModerationRuleDeleteContributions.length === 0 &&
|
|
163
|
+
autoModerationActionContributions.length === 0 &&
|
|
104
164
|
guildMessageContributions.length === 0 &&
|
|
105
165
|
guildVoiceSessionContributions.length === 0 &&
|
|
106
166
|
reactionContributions.length === 0 &&
|
|
@@ -120,6 +180,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
120
180
|
guildEmojiUpdateContributions.length === 0)
|
|
121
181
|
return { stop: () => Promise.resolve(), queueDepth: () => 0 };
|
|
122
182
|
const queue = new BoundedGuildEventQueue(logger);
|
|
183
|
+
const privilegedMetadata = new PrivilegedMessageMetadataCache();
|
|
123
184
|
const voiceSessions = new Map();
|
|
124
185
|
const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
|
|
125
186
|
? new BoundedGuildEventQueue(logger)
|
|
@@ -149,6 +210,21 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
149
210
|
});
|
|
150
211
|
});
|
|
151
212
|
}
|
|
213
|
+
if (privilegedMessageUpdateContributions.length > 0) {
|
|
214
|
+
client.on(Events.MessageCreate, (message) => {
|
|
215
|
+
if (!message.guildId)
|
|
216
|
+
return;
|
|
217
|
+
const source = privilegedSource(message);
|
|
218
|
+
if (!source.resolved)
|
|
219
|
+
return;
|
|
220
|
+
privilegedMetadata.remember({
|
|
221
|
+
guildId: message.guildId,
|
|
222
|
+
channelId: message.channelId,
|
|
223
|
+
messageId: message.id,
|
|
224
|
+
source,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
152
228
|
if (privilegedMessageContributions.length > 0) {
|
|
153
229
|
client.on(Events.MessageCreate, (message) => {
|
|
154
230
|
if (!message.guildId || !privilegedEligibilityQueue)
|
|
@@ -159,6 +235,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
159
235
|
for (const item of privilegedMessageContributions) {
|
|
160
236
|
try {
|
|
161
237
|
if ((await installations.isModuleEnabled(message.guildId, item.moduleId)) &&
|
|
238
|
+
(await featureContributionAvailable(services, message.guildId, item.moduleId, item.contribution.featureId)) &&
|
|
162
239
|
(await item.contribution.isEligible({
|
|
163
240
|
serverId: message.guildId,
|
|
164
241
|
channelId: message.channelId,
|
|
@@ -167,6 +244,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
167
244
|
userIsSystem: message.author.system || message.system,
|
|
168
245
|
webhookId: message.webhookId,
|
|
169
246
|
content: message.content,
|
|
247
|
+
source: privilegedSource(message),
|
|
170
248
|
services,
|
|
171
249
|
}))) {
|
|
172
250
|
eligible.push(item);
|
|
@@ -241,6 +319,32 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
241
319
|
queue.enqueue(message.guildId, () => handleMessageUpdate(message));
|
|
242
320
|
});
|
|
243
321
|
}
|
|
322
|
+
if (privilegedMessageUpdateContributions.length > 0) {
|
|
323
|
+
client.on(Events.MessageUpdate, (_before, message) => {
|
|
324
|
+
if (message.guildId)
|
|
325
|
+
queue.enqueue(message.guildId, () => handlePrivilegedMessageUpdate(message));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (autoModerationRuleCreateContributions.length > 0) {
|
|
329
|
+
client.on(Events.AutoModerationRuleCreate, (rule) => {
|
|
330
|
+
queue.enqueue(rule.guild.id, () => handleAutoModerationRule(autoModerationRuleCreateContributions, rule, "create"));
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (autoModerationRuleUpdateContributions.length > 0) {
|
|
334
|
+
client.on(Events.AutoModerationRuleUpdate, (_before, rule) => {
|
|
335
|
+
queue.enqueue(rule.guild.id, () => handleAutoModerationRule(autoModerationRuleUpdateContributions, rule, "update"));
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (autoModerationRuleDeleteContributions.length > 0) {
|
|
339
|
+
client.on(Events.AutoModerationRuleDelete, (rule) => {
|
|
340
|
+
queue.enqueue(rule.guild.id, () => handleAutoModerationRule(autoModerationRuleDeleteContributions, rule, "delete"));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (autoModerationActionContributions.length > 0) {
|
|
344
|
+
client.on(Events.AutoModerationActionExecution, (execution) => {
|
|
345
|
+
queue.enqueue(execution.guild.id, () => handleAutoModerationAction(execution));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
244
348
|
if (messageDeleteContributions.length > 0) {
|
|
245
349
|
client.on(Events.MessageDelete, (message) => {
|
|
246
350
|
if (message.guildId)
|
|
@@ -335,7 +439,8 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
335
439
|
const installations = availabilityAwareInstallations(services, modulesById);
|
|
336
440
|
for (const { moduleId, contribution } of contributions) {
|
|
337
441
|
try {
|
|
338
|
-
if (!(await installations.isModuleEnabled(message.guildId, moduleId))
|
|
442
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId)) ||
|
|
443
|
+
!(await featureContributionAvailable(services, message.guildId, moduleId, contribution.featureId)))
|
|
339
444
|
continue;
|
|
340
445
|
await contribution.execute({
|
|
341
446
|
serverId: message.guildId,
|
|
@@ -345,6 +450,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
345
450
|
userIsBot: message.author.bot,
|
|
346
451
|
webhookId: message.webhookId,
|
|
347
452
|
content: message.content,
|
|
453
|
+
source: privilegedSource(message),
|
|
348
454
|
services,
|
|
349
455
|
deleteSource: async () => {
|
|
350
456
|
await message.delete();
|
|
@@ -362,6 +468,138 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
362
468
|
}
|
|
363
469
|
}
|
|
364
470
|
}
|
|
471
|
+
async function handlePrivilegedMessageUpdate(message) {
|
|
472
|
+
if (!message.guildId)
|
|
473
|
+
return;
|
|
474
|
+
const payloadSource = privilegedSource(message);
|
|
475
|
+
const cachedSource = privilegedMetadata.resolve({
|
|
476
|
+
guildId: message.guildId,
|
|
477
|
+
channelId: message.channelId,
|
|
478
|
+
messageId: message.id,
|
|
479
|
+
});
|
|
480
|
+
const source = payloadSource.resolved
|
|
481
|
+
? payloadSource
|
|
482
|
+
: (cachedSource ?? { resolved: false });
|
|
483
|
+
if (source.resolved)
|
|
484
|
+
privilegedMetadata.remember({
|
|
485
|
+
guildId: message.guildId,
|
|
486
|
+
channelId: message.channelId,
|
|
487
|
+
messageId: message.id,
|
|
488
|
+
source,
|
|
489
|
+
});
|
|
490
|
+
const observedAt = new Date();
|
|
491
|
+
const content = typeof message.content === "string"
|
|
492
|
+
? { present: true, value: message.content }
|
|
493
|
+
: { present: false };
|
|
494
|
+
const installations = availabilityAwareInstallations(services, modulesById);
|
|
495
|
+
for (const { moduleId, contribution, } of privilegedMessageUpdateContributions) {
|
|
496
|
+
try {
|
|
497
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId)) ||
|
|
498
|
+
!(await featureContributionAvailable(services, message.guildId, moduleId, contribution.featureId)))
|
|
499
|
+
continue;
|
|
500
|
+
await contribution.execute({
|
|
501
|
+
serverId: message.guildId,
|
|
502
|
+
channelId: message.channelId,
|
|
503
|
+
messageId: message.id,
|
|
504
|
+
observationVersion: message.editedTimestamp?.toString() ?? observedAt.toISOString(),
|
|
505
|
+
observedAt,
|
|
506
|
+
content,
|
|
507
|
+
source,
|
|
508
|
+
services,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
logger.warn({
|
|
513
|
+
event: "module.privileged_message_update_failed",
|
|
514
|
+
moduleId,
|
|
515
|
+
error: safeError(error),
|
|
516
|
+
messageId: message.id,
|
|
517
|
+
guildId: message.guildId,
|
|
518
|
+
}, "Module privileged message-update event failed");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function handleAutoModerationRule(contributions, rule, eventName) {
|
|
523
|
+
const installations = availabilityAwareInstallations(services, modulesById);
|
|
524
|
+
for (const { moduleId, contribution } of contributions) {
|
|
525
|
+
try {
|
|
526
|
+
if (!(await installations.isModuleEnabled(rule.guild.id, moduleId)))
|
|
527
|
+
continue;
|
|
528
|
+
await contribution.execute({
|
|
529
|
+
serverId: rule.guild.id,
|
|
530
|
+
rule: toRuleSnapshot(rule),
|
|
531
|
+
services,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
logger.warn({
|
|
536
|
+
event: `module.auto_moderation_rule_${eventName}_failed`,
|
|
537
|
+
moduleId,
|
|
538
|
+
guildId: rule.guild.id,
|
|
539
|
+
ruleId: rule.id,
|
|
540
|
+
error: safeError(error),
|
|
541
|
+
}, "Module Auto Moderation rule event failed");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function handleAutoModerationAction(execution) {
|
|
546
|
+
const installations = availabilityAwareInstallations(services, modulesById);
|
|
547
|
+
const action = fromDiscordAction(execution.action);
|
|
548
|
+
const executionId = createHash("sha256")
|
|
549
|
+
.update(JSON.stringify([
|
|
550
|
+
execution.guild.id,
|
|
551
|
+
execution.ruleId,
|
|
552
|
+
execution.userId,
|
|
553
|
+
execution.channelId,
|
|
554
|
+
execution.messageId,
|
|
555
|
+
]))
|
|
556
|
+
.digest("hex");
|
|
557
|
+
const evidenceReference = options?.evidenceStore && execution.channelId && execution.content
|
|
558
|
+
? options.evidenceStore.capture({
|
|
559
|
+
source: "discord_auto_moderation",
|
|
560
|
+
sourceEventId: executionId,
|
|
561
|
+
guildId: execution.guild.id,
|
|
562
|
+
channelId: execution.channelId,
|
|
563
|
+
messageId: execution.messageId,
|
|
564
|
+
authorUserId: execution.userId,
|
|
565
|
+
content: execution.content,
|
|
566
|
+
})
|
|
567
|
+
: null;
|
|
568
|
+
try {
|
|
569
|
+
for (const { moduleId, contribution, } of autoModerationActionContributions) {
|
|
570
|
+
try {
|
|
571
|
+
if (!(await installations.isModuleEnabled(execution.guild.id, moduleId)))
|
|
572
|
+
continue;
|
|
573
|
+
await contribution.execute({
|
|
574
|
+
serverId: execution.guild.id,
|
|
575
|
+
ruleId: execution.ruleId,
|
|
576
|
+
userId: execution.userId,
|
|
577
|
+
channelId: execution.channelId,
|
|
578
|
+
messageId: execution.messageId,
|
|
579
|
+
alertSystemMessageId: execution.alertSystemMessageId,
|
|
580
|
+
action,
|
|
581
|
+
executionId,
|
|
582
|
+
evidenceReference,
|
|
583
|
+
occurredAt: new Date(),
|
|
584
|
+
services,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
logger.warn({
|
|
589
|
+
event: "module.auto_moderation_action_execution_failed",
|
|
590
|
+
moduleId,
|
|
591
|
+
guildId: execution.guild.id,
|
|
592
|
+
ruleId: execution.ruleId,
|
|
593
|
+
error: safeError(error),
|
|
594
|
+
}, "Module Auto Moderation action event failed");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
if (evidenceReference)
|
|
600
|
+
options?.evidenceStore?.discard(evidenceReference);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
365
603
|
async function handleGuildMessageCreate(message) {
|
|
366
604
|
if (!message.guildId || !message.channelId)
|
|
367
605
|
return;
|
|
@@ -862,6 +1100,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
862
1100
|
await guildMessageQueue?.stop();
|
|
863
1101
|
await queue.stop();
|
|
864
1102
|
voiceSessions.clear();
|
|
1103
|
+
privilegedMetadata.clear();
|
|
865
1104
|
},
|
|
866
1105
|
queueDepth: () => queue.queueDepth() +
|
|
867
1106
|
(privilegedEligibilityQueue?.queueDepth() ?? 0) +
|
|
@@ -1504,19 +1743,46 @@ export class DiscordResourceInspection {
|
|
|
1504
1743
|
}
|
|
1505
1744
|
async resolveMembers(input) {
|
|
1506
1745
|
const guild = await this.#resolveMemberGuild(input);
|
|
1507
|
-
|
|
1508
|
-
|
|
1746
|
+
const members = new Map();
|
|
1747
|
+
for (const userId of input.userIds) {
|
|
1748
|
+
const cached = guild.members.cache?.get(userId);
|
|
1749
|
+
if (cached)
|
|
1750
|
+
members.set(userId, cached);
|
|
1751
|
+
}
|
|
1752
|
+
const missing = input.userIds.filter((userId) => !members.has(userId));
|
|
1753
|
+
const canBulkResolve = this.client.options?.intents?.has(GatewayIntentBits.GuildMembers);
|
|
1754
|
+
if (missing.length > 0 && canBulkResolve) {
|
|
1755
|
+
const fetched = await guild.members
|
|
1756
|
+
.fetch({ user: missing, time: 5_000 })
|
|
1757
|
+
.catch(() => null);
|
|
1758
|
+
if (fetched)
|
|
1759
|
+
for (const member of fetched.values())
|
|
1760
|
+
if (missing.includes(member.id))
|
|
1761
|
+
members.set(member.id, member);
|
|
1762
|
+
}
|
|
1763
|
+
else if (missing.length > 0) {
|
|
1764
|
+
const fetched = await Promise.all(missing.map((userId) => fetchExactMember(guild, userId, "member").catch((error) => {
|
|
1509
1765
|
if (isUnknownMember(error))
|
|
1510
1766
|
return null;
|
|
1511
1767
|
throw error;
|
|
1512
|
-
});
|
|
1768
|
+
})));
|
|
1769
|
+
for (const member of fetched)
|
|
1770
|
+
if (member)
|
|
1771
|
+
members.set(member.id, member);
|
|
1772
|
+
}
|
|
1773
|
+
return input.userIds.map((userId) => {
|
|
1774
|
+
const member = members.get(userId) ?? null;
|
|
1775
|
+
const cachedUser = this.client.users?.cache?.get(userId);
|
|
1513
1776
|
const avatarUrl = memberAvatarUrl(member);
|
|
1514
1777
|
return {
|
|
1515
1778
|
userId,
|
|
1516
|
-
displayName: member?.displayName ??
|
|
1779
|
+
displayName: member?.displayName ??
|
|
1780
|
+
cachedUser?.globalName ??
|
|
1781
|
+
cachedUser?.username ??
|
|
1782
|
+
null,
|
|
1517
1783
|
...(avatarUrl ? { avatarUrl } : {}),
|
|
1518
1784
|
};
|
|
1519
|
-
})
|
|
1785
|
+
});
|
|
1520
1786
|
}
|
|
1521
1787
|
async searchMembers(input) {
|
|
1522
1788
|
validateSnowflake(input.guildId, "guild");
|
|
@@ -2061,7 +2327,7 @@ function validateSnowflake(value, label) {
|
|
|
2061
2327
|
throw new Error(`Invalid Discord ${label} ID`);
|
|
2062
2328
|
}
|
|
2063
2329
|
function discordAuditReason(input) {
|
|
2064
|
-
return `Helyx ${input.moduleId}: ${input.reason.trim()}
|
|
2330
|
+
return `Helyx ${input.moduleId}${input.operationKey ? ` ${input.operationKey}` : ""}: ${input.reason.trim()}`.slice(0, 512);
|
|
2065
2331
|
}
|
|
2066
2332
|
function normalizePermissionName(permission) {
|
|
2067
2333
|
return permission.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
package/dist/guilds.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
|
|
|
2
2
|
import type { ModuleDefinition } from "@helyx/sdk";
|
|
3
3
|
import { type Client } from "discord.js";
|
|
4
4
|
import type { InteractionLogger } from "./interactions.js";
|
|
5
|
+
import type { HostedBotInviteGate, HostedGuildOwnerObserver, HostedGuildSafety } from "./hosted-server-moderation.js";
|
|
5
6
|
export interface GuildReconciliationHealth {
|
|
6
7
|
status: "pending" | "running" | "healthy" | "failed" | "stopped";
|
|
7
8
|
lastStartedAt?: string;
|
|
@@ -21,5 +22,5 @@ export interface GuildTrackingPreparation {
|
|
|
21
22
|
health(): GuildReconciliationHealth;
|
|
22
23
|
stop(): void;
|
|
23
24
|
}
|
|
24
|
-
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string): GuildTrackingPreparation;
|
|
25
|
+
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string, hostedSafety?: HostedGuildSafety, hostedInviteGate?: HostedBotInviteGate, hostedOwnerObserver?: HostedGuildOwnerObserver | undefined): GuildTrackingPreparation;
|
|
25
26
|
//# sourceMappingURL=guilds.d.ts.map
|
package/dist/guilds.js
CHANGED
|
@@ -3,7 +3,7 @@ import { safeError } from "@helyx/core";
|
|
|
3
3
|
import { Events } from "discord.js";
|
|
4
4
|
const RECONCILIATION_PAGE_SIZE = 250;
|
|
5
5
|
const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
|
|
6
|
-
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId) {
|
|
6
|
+
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId, hostedSafety, hostedInviteGate, hostedOwnerObserver = hostedSafety) {
|
|
7
7
|
if (!Number.isSafeInteger(reconciliationIntervalMs) ||
|
|
8
8
|
reconciliationIntervalMs < 60_000)
|
|
9
9
|
throw new Error("Guild reconciliation interval must be at least one minute");
|
|
@@ -12,6 +12,11 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
12
12
|
let stopped = false;
|
|
13
13
|
let reconciliation;
|
|
14
14
|
let timer;
|
|
15
|
+
const admissionRetries = new Map();
|
|
16
|
+
const inviteGateRejections = new Map();
|
|
17
|
+
const ownerObservations = hostedOwnerObserver
|
|
18
|
+
? new OwnerObservationQueue(hostedOwnerObserver, logger)
|
|
19
|
+
: undefined;
|
|
15
20
|
let health = {
|
|
16
21
|
status: "pending",
|
|
17
22
|
examinedRows: 0,
|
|
@@ -21,11 +26,14 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
21
26
|
unavailableGuilds: 0,
|
|
22
27
|
};
|
|
23
28
|
async function syncGuild(guild, firstObservation = false) {
|
|
29
|
+
const cachedOwner = guild.members?.cache.get(guild.ownerId)?.user ??
|
|
30
|
+
guild.client?.users.cache.get(guild.ownerId);
|
|
24
31
|
const persist = async (connection) => {
|
|
25
32
|
const installations = new InstallationRepository(connection);
|
|
26
33
|
const record = await installations.syncGuild({
|
|
27
34
|
guildId: guild.id,
|
|
28
35
|
ownerUserId: guild.ownerId,
|
|
36
|
+
...(cachedOwner ? { ownerUsername: cachedOwner.username } : {}),
|
|
29
37
|
name: guild.name,
|
|
30
38
|
...(ownership ? { ownership } : {}),
|
|
31
39
|
});
|
|
@@ -57,17 +65,105 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
57
65
|
await withTransaction(database, persist);
|
|
58
66
|
else
|
|
59
67
|
await persist(database);
|
|
68
|
+
if (cachedOwner && hostedOwnerObserver)
|
|
69
|
+
await hostedOwnerObserver.observeOwner(guild);
|
|
70
|
+
else
|
|
71
|
+
ownerObservations?.enqueue(guild);
|
|
72
|
+
}
|
|
73
|
+
async function leaveInviteRejectedGuild(guild) {
|
|
74
|
+
const reasonCode = inviteGateRejections.get(guild.id);
|
|
75
|
+
if (!reasonCode)
|
|
76
|
+
return;
|
|
77
|
+
await guild.leave();
|
|
78
|
+
logger.info({
|
|
79
|
+
event: "guild.invite_gate_enforced",
|
|
80
|
+
guildId: guild.id,
|
|
81
|
+
reasonCode,
|
|
82
|
+
}, "New guild was rejected by the Bot Invite gate");
|
|
83
|
+
}
|
|
84
|
+
async function admitAndSync(guild, firstObservation) {
|
|
85
|
+
if (inviteGateRejections.has(guild.id)) {
|
|
86
|
+
await leaveInviteRejectedGuild(guild);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const [blacklist, inviteGate] = await Promise.allSettled([
|
|
90
|
+
hostedSafety
|
|
91
|
+
? hostedSafety.isBlacklisted(guild.id)
|
|
92
|
+
: Promise.resolve(false),
|
|
93
|
+
hostedInviteGate ? hostedInviteGate.isEnabled() : Promise.resolve(true),
|
|
94
|
+
]);
|
|
95
|
+
if (hostedSafety && blacklist.status === "fulfilled" && blacklist.value) {
|
|
96
|
+
const enforcement = await Promise.allSettled([
|
|
97
|
+
hostedSafety.recordAutomaticLeave(guild.id),
|
|
98
|
+
guild.leave(),
|
|
99
|
+
]);
|
|
100
|
+
if (enforcement.some((result) => result.status === "rejected"))
|
|
101
|
+
throw new Error("Blacklisted guild enforcement was unavailable");
|
|
102
|
+
logger.info({ event: "guild.blacklist_enforced", guildId: guild.id }, "Blacklisted guild was rejected before initialization");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (inviteGate.status === "rejected") {
|
|
106
|
+
inviteGateRejections.set(guild.id, "gate_unavailable");
|
|
107
|
+
logger.warn({
|
|
108
|
+
event: "guild.invite_gate_check_failed",
|
|
109
|
+
guildId: guild.id,
|
|
110
|
+
reasonCode: "gate_unavailable",
|
|
111
|
+
}, "Bot Invite gate was unavailable; the new guild will be rejected");
|
|
112
|
+
await leaveInviteRejectedGuild(guild);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!inviteGate.value) {
|
|
116
|
+
inviteGateRejections.set(guild.id, "gate_paused");
|
|
117
|
+
await leaveInviteRejectedGuild(guild);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (blacklist.status === "rejected")
|
|
121
|
+
throw new Error("Hosted guild safety lookup was unavailable");
|
|
122
|
+
await syncGuild(guild, firstObservation);
|
|
123
|
+
const retry = admissionRetries.get(guild.id);
|
|
124
|
+
if (retry)
|
|
125
|
+
clearTimeout(retry);
|
|
126
|
+
admissionRetries.delete(guild.id);
|
|
127
|
+
}
|
|
128
|
+
function scheduleAdmissionRetry(guild) {
|
|
129
|
+
if (stopped || admissionRetries.has(guild.id))
|
|
130
|
+
return;
|
|
131
|
+
const retry = setTimeout(() => {
|
|
132
|
+
admissionRetries.delete(guild.id);
|
|
133
|
+
if (stopped || !guild.available)
|
|
134
|
+
return;
|
|
135
|
+
void admitAndSync(guild, true).catch(() => {
|
|
136
|
+
logger.error({
|
|
137
|
+
event: "guild.admission_retry_failed",
|
|
138
|
+
guildId: guild.id,
|
|
139
|
+
reasonCode: "admission_unavailable",
|
|
140
|
+
}, "Guild admission retry failed");
|
|
141
|
+
scheduleAdmissionRetry(guild);
|
|
142
|
+
});
|
|
143
|
+
}, 30_000);
|
|
144
|
+
retry.unref();
|
|
145
|
+
admissionRetries.set(guild.id, retry);
|
|
60
146
|
}
|
|
61
147
|
client.on(Events.GuildCreate, (guild) => {
|
|
62
|
-
void
|
|
148
|
+
void admitAndSync(guild, true).catch(() => {
|
|
63
149
|
logger.error({
|
|
64
|
-
event: "guild.
|
|
150
|
+
event: "guild.admission_failed",
|
|
65
151
|
guildId: guild.id,
|
|
66
|
-
|
|
67
|
-
}, "Guild
|
|
152
|
+
reasonCode: "admission_unavailable",
|
|
153
|
+
}, "Guild admission failed closed");
|
|
154
|
+
scheduleAdmissionRetry(guild);
|
|
68
155
|
});
|
|
69
156
|
});
|
|
157
|
+
client.on(Events.GuildUpdate, (_previous, guild) => {
|
|
158
|
+
if (!inviteGateRejections.has(guild.id))
|
|
159
|
+
ownerObservations?.enqueue(guild);
|
|
160
|
+
});
|
|
70
161
|
client.on(Events.GuildDelete, (guild) => {
|
|
162
|
+
const retry = admissionRetries.get(guild.id);
|
|
163
|
+
if (retry)
|
|
164
|
+
clearTimeout(retry);
|
|
165
|
+
admissionRetries.delete(guild.id);
|
|
166
|
+
inviteGateRejections.delete(guild.id);
|
|
71
167
|
void repository
|
|
72
168
|
.markGuildRemoved(guild.id, ownership)
|
|
73
169
|
.then(async (changed) => {
|
|
@@ -110,9 +206,23 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
110
206
|
const observedGuildIds = new Set(client.guilds.cache.keys());
|
|
111
207
|
const unavailableGuilds = [...client.guilds.cache.values()].filter((guild) => !guild.available).length;
|
|
112
208
|
try {
|
|
209
|
+
const blacklistedGuildIds = new Set();
|
|
210
|
+
if (hostedSafety) {
|
|
211
|
+
const observed = [...observedGuildIds];
|
|
212
|
+
for (let offset = 0; offset < observed.length; offset += RECONCILIATION_PAGE_SIZE) {
|
|
213
|
+
const page = await hostedSafety.blacklistedGuildIds(observed.slice(offset, offset + RECONCILIATION_PAGE_SIZE));
|
|
214
|
+
for (const guildId of page)
|
|
215
|
+
blacklistedGuildIds.add(guildId);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
113
218
|
for (const guild of client.guilds.cache.values()) {
|
|
114
219
|
if (stopped)
|
|
115
220
|
return;
|
|
221
|
+
if (blacklistedGuildIds.has(guild.id)) {
|
|
222
|
+
await hostedSafety.recordAutomaticLeave(guild.id);
|
|
223
|
+
await guild.leave();
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
116
226
|
await syncGuild(guild);
|
|
117
227
|
refreshedGuilds += 1;
|
|
118
228
|
}
|
|
@@ -211,8 +321,57 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
211
321
|
if (timer)
|
|
212
322
|
clearInterval(timer);
|
|
213
323
|
timer = undefined;
|
|
324
|
+
for (const retry of admissionRetries.values())
|
|
325
|
+
clearTimeout(retry);
|
|
326
|
+
admissionRetries.clear();
|
|
327
|
+
inviteGateRejections.clear();
|
|
328
|
+
ownerObservations?.stop();
|
|
214
329
|
health = { ...health, status: "stopped", nextScheduledAt: undefined };
|
|
215
330
|
};
|
|
216
331
|
return prepare;
|
|
217
332
|
}
|
|
333
|
+
class OwnerObservationQueue {
|
|
334
|
+
observer;
|
|
335
|
+
logger;
|
|
336
|
+
#pending = new Map();
|
|
337
|
+
#active = 0;
|
|
338
|
+
#stopped = false;
|
|
339
|
+
constructor(observer, logger) {
|
|
340
|
+
this.observer = observer;
|
|
341
|
+
this.logger = logger;
|
|
342
|
+
}
|
|
343
|
+
enqueue(guild) {
|
|
344
|
+
if (this.#stopped)
|
|
345
|
+
return;
|
|
346
|
+
this.#pending.set(guild.id, guild);
|
|
347
|
+
this.#drain();
|
|
348
|
+
}
|
|
349
|
+
stop() {
|
|
350
|
+
this.#stopped = true;
|
|
351
|
+
this.#pending.clear();
|
|
352
|
+
}
|
|
353
|
+
#drain() {
|
|
354
|
+
while (!this.#stopped && this.#active < 2 && this.#pending.size > 0) {
|
|
355
|
+
const next = this.#pending.entries().next().value;
|
|
356
|
+
if (!next)
|
|
357
|
+
return;
|
|
358
|
+
const [guildId, guild] = next;
|
|
359
|
+
this.#pending.delete(guildId);
|
|
360
|
+
this.#active += 1;
|
|
361
|
+
void this.observer
|
|
362
|
+
.observeOwner(guild)
|
|
363
|
+
.catch(() => {
|
|
364
|
+
this.logger.warn({
|
|
365
|
+
event: "guild.owner_observation_failed",
|
|
366
|
+
guildId,
|
|
367
|
+
reasonCode: "owner_identity_unavailable",
|
|
368
|
+
}, "Guild owner identity observation was deferred");
|
|
369
|
+
})
|
|
370
|
+
.finally(() => {
|
|
371
|
+
this.#active -= 1;
|
|
372
|
+
this.#drain();
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
218
377
|
//# sourceMappingURL=guilds.js.map
|