@helyx/bot 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +16 -1
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/discord-analytics.d.ts +3 -0
  23. package/dist/discord-analytics.js +49 -5
  24. package/dist/discord-auto-moderation-service.d.ts +21 -0
  25. package/dist/discord-auto-moderation-service.js +425 -0
  26. package/dist/discord-guild-health.d.ts +2 -2
  27. package/dist/discord-member-enforcement-service.d.ts +26 -0
  28. package/dist/discord-member-enforcement-service.js +590 -0
  29. package/dist/discord-message-enforcement-service.d.ts +9 -0
  30. package/dist/discord-message-enforcement-service.js +102 -0
  31. package/dist/discord-violation-thread-service.d.ts +33 -0
  32. package/dist/discord-violation-thread-service.js +789 -0
  33. package/dist/ecko-evidence-receiver.d.ts +32 -0
  34. package/dist/ecko-evidence-receiver.js +173 -0
  35. package/dist/ecko-transport-auth.d.ts +19 -0
  36. package/dist/ecko-transport-auth.js +80 -0
  37. package/dist/ecko-transport-routes.d.ts +19 -0
  38. package/dist/ecko-transport-routes.js +130 -0
  39. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  40. package/dist/embedded-auto-moderation-pool.js +324 -0
  41. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  42. package/dist/embedded-auto-moderation-scanner.js +263 -0
  43. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  44. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  45. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  46. package/dist/embedded-auto-moderation-worker.js +13 -0
  47. package/dist/events.d.ts +5 -1
  48. package/dist/events.js +243 -4
  49. package/dist/guilds.d.ts +2 -2
  50. package/dist/guilds.js +14 -8
  51. package/dist/hosted-server-moderation.d.ts +16 -5
  52. package/dist/hosted-server-moderation.js +32 -4
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +84 -14
  55. package/dist/moderation-action-registry.d.ts +11 -0
  56. package/dist/moderation-action-registry.js +375 -0
  57. package/dist/moderation-case-disassociation.d.ts +13 -0
  58. package/dist/moderation-case-disassociation.js +83 -0
  59. package/dist/moderation-platform-services.d.ts +27 -0
  60. package/dist/moderation-platform-services.js +50 -0
  61. package/dist/modules.d.ts +6 -0
  62. package/dist/modules.js +65 -3
  63. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  64. package/dist/privileged-message-metadata-cache.js +70 -0
  65. package/dist/scheduled-task-runner.js +24 -1
  66. package/dist/shard-contracts.d.ts +151 -1
  67. package/dist/shard-contracts.js +142 -1
  68. package/dist/shard-coordinator.d.ts +1 -1
  69. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  70. package/dist/shard-ecko-scanner-registry.js +96 -0
  71. package/dist/shard-router.d.ts +23 -0
  72. package/dist/shard-router.js +69 -5
  73. package/dist/shard-supervisor.d.ts +11 -0
  74. package/dist/shard-supervisor.js +67 -4
  75. package/dist/shard-worker.d.ts +15 -1
  76. package/dist/shard-worker.js +95 -17
  77. package/dist/transient-violation-evidence.d.ts +39 -0
  78. package/dist/transient-violation-evidence.js +132 -0
  79. package/documentation/README.md +2 -0
  80. package/documentation/examples/.env.example +12 -0
  81. package/documentation/guides/self-hosting.md +17 -1
  82. package/documentation/modules/auto-moderation.md +51 -0
  83. package/documentation/modules/moderation.md +42 -0
  84. package/documentation/reference/environment.md +11 -0
  85. package/package.json +17 -5
@@ -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) +
@@ -2088,7 +2327,7 @@ function validateSnowflake(value, label) {
2088
2327
  throw new Error(`Invalid Discord ${label} ID`);
2089
2328
  }
2090
2329
  function discordAuditReason(input) {
2091
- return `Helyx ${input.moduleId}: ${input.reason.trim()}`;
2330
+ return `Helyx ${input.moduleId}${input.operationKey ? ` ${input.operationKey}` : ""}: ${input.reason.trim()}`.slice(0, 512);
2092
2331
  }
2093
2332
  function normalizePermissionName(permission) {
2094
2333
  return permission.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
package/dist/guilds.d.ts CHANGED
@@ -2,7 +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, HostedGuildSafety } from "./hosted-server-moderation.js";
5
+ import type { HostedBotInviteGate, HostedGuildOwnerObserver, HostedGuildSafety } from "./hosted-server-moderation.js";
6
6
  export interface GuildReconciliationHealth {
7
7
  status: "pending" | "running" | "healthy" | "failed" | "stopped";
8
8
  lastStartedAt?: string;
@@ -22,5 +22,5 @@ export interface GuildTrackingPreparation {
22
22
  health(): GuildReconciliationHealth;
23
23
  stop(): void;
24
24
  }
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): 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;
26
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, hostedSafety, hostedInviteGate) {
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");
@@ -14,8 +14,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
14
14
  let timer;
15
15
  const admissionRetries = new Map();
16
16
  const inviteGateRejections = new Map();
17
- const ownerObservations = hostedSafety
18
- ? new OwnerObservationQueue(hostedSafety, logger)
17
+ const ownerObservations = hostedOwnerObserver
18
+ ? new OwnerObservationQueue(hostedOwnerObserver, logger)
19
19
  : undefined;
20
20
  let health = {
21
21
  status: "pending",
@@ -26,11 +26,14 @@ export function installGuildTracking(client, database, modules, logger, enabledB
26
26
  unavailableGuilds: 0,
27
27
  };
28
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);
29
31
  const persist = async (connection) => {
30
32
  const installations = new InstallationRepository(connection);
31
33
  const record = await installations.syncGuild({
32
34
  guildId: guild.id,
33
35
  ownerUserId: guild.ownerId,
36
+ ...(cachedOwner ? { ownerUsername: cachedOwner.username } : {}),
34
37
  name: guild.name,
35
38
  ...(ownership ? { ownership } : {}),
36
39
  });
@@ -62,7 +65,10 @@ export function installGuildTracking(client, database, modules, logger, enabledB
62
65
  await withTransaction(database, persist);
63
66
  else
64
67
  await persist(database);
65
- ownerObservations?.enqueue(guild);
68
+ if (cachedOwner && hostedOwnerObserver)
69
+ await hostedOwnerObserver.observeOwner(guild);
70
+ else
71
+ ownerObservations?.enqueue(guild);
66
72
  }
67
73
  async function leaveInviteRejectedGuild(guild) {
68
74
  const reasonCode = inviteGateRejections.get(guild.id);
@@ -325,13 +331,13 @@ export function installGuildTracking(client, database, modules, logger, enabledB
325
331
  return prepare;
326
332
  }
327
333
  class OwnerObservationQueue {
328
- safety;
334
+ observer;
329
335
  logger;
330
336
  #pending = new Map();
331
337
  #active = 0;
332
338
  #stopped = false;
333
- constructor(safety, logger) {
334
- this.safety = safety;
339
+ constructor(observer, logger) {
340
+ this.observer = observer;
335
341
  this.logger = logger;
336
342
  }
337
343
  enqueue(guild) {
@@ -352,7 +358,7 @@ class OwnerObservationQueue {
352
358
  const [guildId, guild] = next;
353
359
  this.#pending.delete(guildId);
354
360
  this.#active += 1;
355
- void this.safety
361
+ void this.observer
356
362
  .observeOwner(guild)
357
363
  .catch(() => {
358
364
  this.logger.warn({
@@ -1,4 +1,4 @@
1
- import { type DatabasePool } from "@helyx/database";
1
+ import { type DatabasePool, type HostedServerLeaveDeliveryOutcome } from "@helyx/database";
2
2
  import type { Client, Guild } from "discord.js";
3
3
  import { z } from "zod";
4
4
  import type { InteractionLogger } from "./interactions.js";
@@ -14,9 +14,9 @@ declare const resultSchema: z.ZodObject<{
14
14
  requires_review: "requires_review";
15
15
  failed: "failed";
16
16
  queued: "queued";
17
- already_blacklisted: "already_blacklisted";
18
17
  not_currently_installed: "not_currently_installed";
19
18
  accepted_for_retry: "accepted_for_retry";
19
+ already_blacklisted: "already_blacklisted";
20
20
  }>;
21
21
  paidSlotsReleased: z.ZodNumber;
22
22
  }, z.core.$loose>;
@@ -45,7 +45,7 @@ export declare class HostedServerModerationClient implements DeveloperServerMode
45
45
  idempotencyKey: string;
46
46
  }): Promise<{
47
47
  operationId: string;
48
- outcome: "completed" | "requires_review" | "failed" | "queued" | "already_blacklisted" | "not_currently_installed" | "accepted_for_retry";
48
+ outcome: "completed" | "requires_review" | "failed" | "queued" | "not_currently_installed" | "accepted_for_retry" | "already_blacklisted";
49
49
  paidSlotsReleased: number;
50
50
  }>;
51
51
  }
@@ -58,11 +58,17 @@ export declare class HostedBotInviteGateClient implements HostedBotInviteGate {
58
58
  constructor(apiUrl: string, secret: string);
59
59
  isEnabled(): Promise<boolean>;
60
60
  }
61
- export interface HostedGuildSafety {
61
+ export interface HostedGuildOwnerObserver {
62
+ observeOwner(guild: Guild): Promise<void>;
63
+ }
64
+ export interface HostedGuildSafety extends HostedGuildOwnerObserver {
62
65
  isBlacklisted(guildId: string): Promise<boolean>;
63
66
  blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
64
67
  recordAutomaticLeave(guildId: string): Promise<string>;
65
- observeOwner(guild: Guild): Promise<void>;
68
+ }
69
+ export interface HostedServerOwnerObservation {
70
+ username: string;
71
+ observedAt: string;
66
72
  }
67
73
  export declare class PostgresHostedGuildSafety implements HostedGuildSafety {
68
74
  #private;
@@ -71,6 +77,7 @@ export declare class PostgresHostedGuildSafety implements HostedGuildSafety {
71
77
  blacklistedGuildIds(guildIds: readonly string[]): Promise<Set<string>>;
72
78
  recordAutomaticLeave(guildId: string): Promise<string>;
73
79
  observeOwner(guild: Guild): Promise<void>;
80
+ refreshOwner(guild: Guild): Promise<HostedServerOwnerObservation>;
74
81
  }
75
82
  export declare class HostedServerLeaveWorker {
76
83
  #private;
@@ -83,6 +90,10 @@ export declare class HostedServerLeaveWorker {
83
90
  } | undefined);
84
91
  start(): void;
85
92
  runOnce(): Promise<void>;
93
+ deliverOperation(input: {
94
+ operationId: string;
95
+ guildId: string;
96
+ }): Promise<HostedServerLeaveDeliveryOutcome>;
86
97
  stop(): Promise<void>;
87
98
  }
88
99
  export {};
@@ -84,13 +84,22 @@ export class PostgresHostedGuildSafety {
84
84
  return this.#repository.enqueueAutomaticLeave(guildId);
85
85
  }
86
86
  async observeOwner(guild) {
87
- const cached = guild.members.cache.get(guild.ownerId)?.user;
87
+ await this.refreshOwner(guild);
88
+ }
89
+ async refreshOwner(guild) {
90
+ const cached = guild.members.cache.get(guild.ownerId)?.user ??
91
+ guild.client.users.cache?.get(guild.ownerId);
88
92
  const user = cached ?? (await guild.client.users.fetch(guild.ownerId));
89
- await this.#repository.observeOwner({
93
+ const observedAt = new Date();
94
+ const persisted = await this.#repository.observeOwner({
90
95
  guildId: guild.id,
91
96
  ownerUserId: guild.ownerId,
92
97
  username: user.username,
98
+ observedAt,
93
99
  });
100
+ if (!persisted)
101
+ throw new Error("Guild owner observation no longer matched the installation");
102
+ return { username: user.username, observedAt: observedAt.toISOString() };
94
103
  }
95
104
  }
96
105
  export class HostedServerLeaveWorker {
@@ -122,6 +131,19 @@ export class HostedServerLeaveWorker {
122
131
  });
123
132
  return this.#active;
124
133
  }
134
+ async deliverOperation(input) {
135
+ if (this.#stopped)
136
+ return "queued";
137
+ await this.#active;
138
+ const claim = await this.#repository.claimLeaveDelivery({
139
+ ...input,
140
+ leaseSeconds: 30,
141
+ ...(this.shardScope ?? {}),
142
+ });
143
+ if (claim)
144
+ return this.#deliver(claim);
145
+ return ((await this.#repository.getLeaveDeliveryOutcome(input.operationId, input.guildId)) ?? "queued");
146
+ }
125
147
  async stop() {
126
148
  this.#stopped = true;
127
149
  if (this.#timer)
@@ -149,7 +171,7 @@ export class HostedServerLeaveWorker {
149
171
  const guild = this.client.guilds.cache.get(claim.guildId);
150
172
  if (!guild) {
151
173
  await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
152
- return;
174
+ return "not_currently_installed";
153
175
  }
154
176
  try {
155
177
  await guild.leave();
@@ -159,12 +181,13 @@ export class HostedServerLeaveWorker {
159
181
  guildId: claim.guildId,
160
182
  operationId: claim.operationId,
161
183
  }, "Hosted server leave completed");
184
+ return "completed";
162
185
  }
163
186
  catch (error) {
164
187
  const failureCode = classifyLeaveFailure(error);
165
188
  if (failureCode === "discord_not_installed") {
166
189
  await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
167
- return;
190
+ return "not_currently_installed";
168
191
  }
169
192
  const outcome = await this.#repository.failLeaveDelivery({
170
193
  claim,
@@ -178,6 +201,11 @@ export class HostedServerLeaveWorker {
178
201
  failureCode,
179
202
  outcome,
180
203
  }, "Hosted server leave will be retried or reviewed");
204
+ return outcome === "requires_review"
205
+ ? outcome
206
+ : outcome === "retry"
207
+ ? "accepted_for_retry"
208
+ : "queued";
181
209
  }
182
210
  }
183
211
  }
package/dist/index.d.ts CHANGED
@@ -11,5 +11,5 @@ export interface BotHandle {
11
11
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
12
12
  export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
13
13
  export declare function readBotVersion(): string;
14
- export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean, guildMembersEnabled?: boolean): GatewayIntentBits[];
14
+ export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean, guildMembersEnabled?: boolean, runtimeProfile?: "hosted" | "self-hosted"): GatewayIntentBits[];
15
15
  //# sourceMappingURL=index.d.ts.map