@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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=embedded-auto-moderation-worker.d.ts.map
@@ -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): ModuleEventHandle;
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
- export function installModuleEvents(client, modules, logger, services) {
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 ?? []).map((contribution) => ({
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
- return Promise.all(input.userIds.map(async (userId) => {
1508
- const member = await fetchExactMember(guild, userId, "member").catch((error) => {
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 ?? null,
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 syncGuild(guild, true).catch((error) => {
148
+ void admitAndSync(guild, true).catch(() => {
63
149
  logger.error({
64
- event: "guild.sync_failed",
150
+ event: "guild.admission_failed",
65
151
  guildId: guild.id,
66
- error: safeError(error),
67
- }, "Guild installation sync failed");
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