@helyx/bot 1.5.1 → 1.5.3

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 (121) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/command-root-catalogue.json +452 -0
  3. package/dist/cached-message-deletion-authority.d.ts +27 -0
  4. package/dist/cached-message-deletion-authority.js +288 -0
  5. package/dist/channel-audit-configuration.d.ts +11 -1
  6. package/dist/channel-audit-configuration.js +28 -6
  7. package/dist/channel-audit-control.js +42 -2
  8. package/dist/channel-audit-permissions.js +76 -3
  9. package/dist/cloud-command-policy-sync.d.ts +26 -0
  10. package/dist/cloud-command-policy-sync.js +198 -0
  11. package/dist/cloud-connector.d.ts +6 -1
  12. package/dist/cloud-connector.js +224 -53
  13. package/dist/cloud-control-dispatcher.js +18 -0
  14. package/dist/command-policy-runtime.d.ts +23 -0
  15. package/dist/command-policy-runtime.js +53 -0
  16. package/dist/command-root-catalogue.d.ts +5 -0
  17. package/dist/command-root-catalogue.js +16 -0
  18. package/dist/control-error-mapping.d.ts +8 -0
  19. package/dist/control-error-mapping.js +60 -0
  20. package/dist/control-errors.d.ts +3 -1
  21. package/dist/control-errors.js +4 -1
  22. package/dist/control-health-routes.d.ts +10 -0
  23. package/dist/control-health-routes.js +28 -0
  24. package/dist/control-server-schemas.d.ts +7 -0
  25. package/dist/control-server-schemas.js +9 -0
  26. package/dist/control-server.d.ts +27 -6
  27. package/dist/control-server.js +103 -122
  28. package/dist/creator-invite-key.d.ts +6 -0
  29. package/dist/creator-invite-key.js +16 -0
  30. package/dist/creator-invite-service.d.ts +3 -1
  31. package/dist/creator-invite-service.js +8 -7
  32. package/dist/creator-onboarding-platform.d.ts +1 -0
  33. package/dist/creator-onboarding-platform.js +5 -2
  34. package/dist/dashboard-feature-control.d.ts +2 -1
  35. package/dist/dashboard-feature-control.js +1 -0
  36. package/dist/dashboard-logging.d.ts +20 -0
  37. package/dist/dashboard-logging.js +39 -0
  38. package/dist/dashboard-module-projection.d.ts +1 -1
  39. package/dist/dashboard-module-projection.js +5 -2
  40. package/dist/dashboard-preview-import-routes.js +8 -0
  41. package/dist/database-bootstrap.d.ts +1 -1
  42. package/dist/database-bootstrap.js +4 -3
  43. package/dist/discord-channel-audit.js +1 -0
  44. package/dist/discord-user-identities.d.ts +14 -0
  45. package/dist/discord-user-identities.js +64 -0
  46. package/dist/emergency-deletion-dispatcher.d.ts +68 -0
  47. package/dist/emergency-deletion-dispatcher.js +502 -0
  48. package/dist/emergency-deletion-rest-transport.d.ts +24 -0
  49. package/dist/emergency-deletion-rest-transport.js +253 -0
  50. package/dist/events.d.ts +5 -0
  51. package/dist/events.js +53 -10
  52. package/dist/guarded-discord-dispatcher.d.ts +29 -0
  53. package/dist/guarded-discord-dispatcher.js +204 -0
  54. package/dist/guild-command-discord-client.d.ts +2 -0
  55. package/dist/guild-command-discord-client.js +7 -0
  56. package/dist/guild-command-projection.d.ts +4 -1
  57. package/dist/guild-command-projection.js +25 -0
  58. package/dist/guild-command-read-service.d.ts +10 -1
  59. package/dist/guild-command-read-service.js +37 -0
  60. package/dist/guild-command-worker.d.ts +19 -4
  61. package/dist/guild-command-worker.js +356 -26
  62. package/dist/guild-message-protection-author-roles.d.ts +7 -0
  63. package/dist/guild-message-protection-author-roles.js +93 -0
  64. package/dist/guild-message-protection-deletion.d.ts +21 -0
  65. package/dist/guild-message-protection-deletion.js +138 -0
  66. package/dist/guild-message-protection-delivery.d.ts +41 -0
  67. package/dist/guild-message-protection-delivery.js +223 -0
  68. package/dist/guild-message-update-metadata.d.ts +32 -0
  69. package/dist/guild-message-update-metadata.js +255 -0
  70. package/dist/guilds.d.ts +2 -1
  71. package/dist/guilds.js +11 -1
  72. package/dist/hosted-server-moderation.d.ts +2 -2
  73. package/dist/index.js +68 -4
  74. package/dist/interactions.d.ts +3 -1
  75. package/dist/interactions.js +77 -12
  76. package/dist/invite-rollup-maintenance.d.ts +9 -0
  77. package/dist/invite-rollup-maintenance.js +12 -0
  78. package/dist/managed-resource-authorization.d.ts +15 -0
  79. package/dist/managed-resource-authorization.js +37 -0
  80. package/dist/managed-resource-create-defaults.d.ts +21 -0
  81. package/dist/managed-resource-create-defaults.js +83 -0
  82. package/dist/managed-resource-query.d.ts +6 -0
  83. package/dist/managed-resource-query.js +15 -0
  84. package/dist/member-role-ids.d.ts +17 -0
  85. package/dist/member-role-ids.js +66 -0
  86. package/dist/message-protection-admission.d.ts +60 -0
  87. package/dist/message-protection-admission.js +279 -0
  88. package/dist/message-protection-cold-data.d.ts +42 -0
  89. package/dist/message-protection-cold-data.js +180 -0
  90. package/dist/message-protection-coordinator.d.ts +73 -0
  91. package/dist/message-protection-coordinator.js +622 -0
  92. package/dist/message-protection-settings.d.ts +40 -0
  93. package/dist/message-protection-settings.js +275 -0
  94. package/dist/message-protection-summary.d.ts +8 -0
  95. package/dist/message-protection-summary.js +98 -0
  96. package/dist/module-availability-control.js +20 -12
  97. package/dist/module-logging.d.ts +3 -2
  98. package/dist/module-logging.js +6 -1
  99. package/dist/modules.js +56 -1
  100. package/dist/mysql-runtime-persistence.d.ts +1 -0
  101. package/dist/mysql-runtime-persistence.js +4 -1
  102. package/dist/nexus-command-recovery-control-routes.d.ts +5 -0
  103. package/dist/nexus-command-recovery-control-routes.js +48 -0
  104. package/dist/nexus-command-references.d.ts +6 -0
  105. package/dist/nexus-command-references.js +33 -0
  106. package/dist/scheduled-task-runner.js +23 -4
  107. package/dist/shard-command-startup.d.ts +13 -0
  108. package/dist/shard-command-startup.js +27 -0
  109. package/dist/shard-contracts.d.ts +84 -0
  110. package/dist/shard-contracts.js +33 -0
  111. package/dist/shard-core-readiness.d.ts +5 -0
  112. package/dist/shard-core-readiness.js +19 -0
  113. package/dist/shard-router.d.ts +3 -0
  114. package/dist/shard-router.js +10 -1
  115. package/dist/shard-supervisor.d.ts +1 -1
  116. package/dist/shard-supervisor.js +131 -43
  117. package/dist/shard-worker.d.ts +1 -0
  118. package/dist/shard-worker.js +37 -15
  119. package/documentation/README.md +1 -0
  120. package/documentation/guides/self-hosting.md +54 -0
  121. package/package.json +18 -17
@@ -0,0 +1,253 @@
1
+ import { DefaultRestOptions, DiscordAPIError, HTTPError, RateLimitError, REST, } from "@discordjs/rest";
2
+ import { Agent } from "undici";
3
+ import { GuardedDiscordDispatcher, GuardedDiscordDispatchError, } from "./guarded-discord-dispatcher.js";
4
+ const scopeFields = [
5
+ "guildId",
6
+ "moduleId",
7
+ "channelId",
8
+ "messageId",
9
+ "admissionToken",
10
+ ];
11
+ const restMethods = [
12
+ "delete",
13
+ "request",
14
+ "queueRequest",
15
+ "resolveRequest",
16
+ "createHandler",
17
+ ];
18
+ const installedRestMethods = Object.freeze(restMethods.map((key) => [key, Reflect.get(REST.prototype, key)]));
19
+ const installedDelete = Reflect.get(REST.prototype, "delete");
20
+ // Identity only; this method is called by the existing REST manager, never detached here.
21
+ const installedStrategy = Reflect.get(DefaultRestOptions, "makeRequest");
22
+ const networkCodes = new Set([
23
+ "ECONNRESET",
24
+ "ECONNREFUSED",
25
+ "ETIMEDOUT",
26
+ "EAI_AGAIN",
27
+ "ENETUNREACH",
28
+ "UND_ERR_CONNECT_TIMEOUT",
29
+ "UND_ERR_HEADERS_TIMEOUT",
30
+ "UND_ERR_BODY_TIMEOUT",
31
+ "UND_ERR_SOCKET",
32
+ ]);
33
+ const failed = Object.freeze({ kind: "failed" });
34
+ /**
35
+ * Dormant transport. Core owns grants/lifecycle and the standard shared REST manager.
36
+ * Its method/strategy/handler surfaces must remain stable: a custom replacement that
37
+ * ignores dispatcher during REST's internal waits is outside this wire-fence contract.
38
+ */
39
+ export class EmergencyMessageDeletionRestTransport {
40
+ #client;
41
+ #rest;
42
+ #agent;
43
+ #applicationId;
44
+ #origin;
45
+ #api;
46
+ #version;
47
+ #options;
48
+ #strategy;
49
+ constructor(options) {
50
+ this.#client = options.client;
51
+ this.#rest = options.client.rest;
52
+ this.#agent = options.agent;
53
+ this.#applicationId = options.expectedApplicationId;
54
+ const api = new URL(this.#rest.options.api);
55
+ if (process.release.name !== "node" ||
56
+ "bun" in process.versions ||
57
+ "deno" in process.versions ||
58
+ !(this.#rest instanceof REST) ||
59
+ !(this.#agent instanceof Agent) ||
60
+ typeof this.#applicationId !== "string" ||
61
+ !/^\d{17,20}$/u.test(this.#applicationId) ||
62
+ api.username ||
63
+ api.password ||
64
+ api.search ||
65
+ api.hash ||
66
+ api.pathname !== "/api" ||
67
+ !(api.protocol === "https:" ||
68
+ (api.protocol === "http:" &&
69
+ ["127.0.0.1", "[::1]"].includes(api.hostname))) ||
70
+ !/^[1-9]\d?$/u.test(this.#rest.options.version) ||
71
+ !Number.isSafeInteger(options.maximumAgeMs) ||
72
+ options.maximumAgeMs < 1 ||
73
+ options.maximumAgeMs > 600_000 ||
74
+ !Number.isSafeInteger(options.maximumDispatches) ||
75
+ options.maximumDispatches < 1 ||
76
+ options.maximumDispatches > 16 ||
77
+ (options.now !== undefined && typeof options.now !== "function"))
78
+ throw new TypeError("Unsupported emergency REST transport context");
79
+ this.#origin = api.origin;
80
+ this.#api = this.#rest.options.api;
81
+ this.#version = this.#rest.options.version;
82
+ // This function selects installed Undici on the supported Node runtime.
83
+ this.#strategy = installedStrategy;
84
+ this.#options = Object.freeze({
85
+ maximumAgeMs: options.maximumAgeMs,
86
+ maximumDispatches: options.maximumDispatches,
87
+ ...(options.now ? { now: options.now } : {}),
88
+ });
89
+ if (!this.#sharedContext())
90
+ throw new TypeError("Unsupported emergency REST request strategy");
91
+ }
92
+ async deleteExact(input, attempt, grant) {
93
+ let guard;
94
+ try {
95
+ const target = Object.freeze({
96
+ guildId: input.guildId,
97
+ moduleId: input.moduleId,
98
+ channelId: input.channelId,
99
+ messageId: input.messageId,
100
+ admissionToken: input.admissionToken,
101
+ });
102
+ if (!validTarget(target) || !grant)
103
+ return failed;
104
+ const authorityScope = grant.scope;
105
+ const authorityCurrent = grant.isCurrent;
106
+ const attemptCurrent = attempt.isCurrent;
107
+ if (!scopeFields.every((key) => authorityScope[key] === target[key]) ||
108
+ typeof authorityCurrent !== "function" ||
109
+ typeof attemptCurrent !== "function")
110
+ return failed;
111
+ let refused = false;
112
+ const current = () => {
113
+ if (refused)
114
+ return false;
115
+ try {
116
+ if (!this.#ready() ||
117
+ grant.scope !== authorityScope ||
118
+ !scopeFields.every((key) => authorityScope[key] === target[key]) ||
119
+ attemptCurrent() !== true ||
120
+ authorityCurrent() !== true ||
121
+ !this.#ready() ||
122
+ attemptCurrent() !== true)
123
+ refused = true;
124
+ }
125
+ catch {
126
+ refused = true;
127
+ }
128
+ return !refused;
129
+ };
130
+ if (!current())
131
+ return failed;
132
+ guard = new GuardedDiscordDispatcher({
133
+ agent: this.#agent,
134
+ origin: this.#origin,
135
+ path: `/api/v${this.#version}/channels/${target.channelId}/messages/${target.messageId}`,
136
+ method: "DELETE",
137
+ isCurrent: current,
138
+ ...this.#options,
139
+ });
140
+ if (!current())
141
+ return failed;
142
+ await Reflect.apply(installedDelete, this.#rest, [
143
+ `/channels/${target.channelId}/messages/${target.messageId}`,
144
+ { dispatcher: guard },
145
+ ]);
146
+ return { kind: "deleted" };
147
+ }
148
+ catch (error) {
149
+ return classify(error);
150
+ }
151
+ finally {
152
+ // Only this request's facade is owned here. Never close/destroy the shared Agent.
153
+ guard?.stop();
154
+ }
155
+ }
156
+ #sharedContext() {
157
+ try {
158
+ return (this.#client.rest === this.#rest &&
159
+ Object.getPrototypeOf(this.#rest) === REST.prototype &&
160
+ this.#rest.agent === this.#agent &&
161
+ !this.#agent.closed &&
162
+ !this.#agent.destroyed &&
163
+ this.#rest.options.api === this.#api &&
164
+ this.#rest.options.version === this.#version &&
165
+ this.#rest.options.authPrefix === "Bot" &&
166
+ this.#rest.options.makeRequest === this.#strategy &&
167
+ DefaultRestOptions.makeRequest === this.#strategy &&
168
+ installedRestMethods.every(([key, method]) => {
169
+ const descriptor = Object.getOwnPropertyDescriptor(REST.prototype, key);
170
+ return (!Object.hasOwn(this.#rest, key) &&
171
+ typeof method === "function" &&
172
+ descriptor !== undefined &&
173
+ "value" in descriptor &&
174
+ descriptor.value === method);
175
+ }));
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ #ready() {
182
+ try {
183
+ return (this.#sharedContext() &&
184
+ this.#client.isReady() === true &&
185
+ this.#client.user?.id === this.#applicationId &&
186
+ this.#client.application?.id === this.#applicationId &&
187
+ this.#sharedContext());
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ }
194
+ function validTarget(input) {
195
+ return ([input.guildId, input.channelId, input.messageId].every((id) => typeof id === "string" && /^\d{17,20}$/u.test(id)) &&
196
+ typeof input.moduleId === "string" &&
197
+ /^helyx\.[a-z0-9][a-z0-9-]{0,79}$/u.test(input.moduleId) &&
198
+ typeof input.admissionToken === "string" &&
199
+ /^[A-Za-z0-9._:-]{1,256}$/u.test(input.admissionToken));
200
+ }
201
+ function classify(error) {
202
+ try {
203
+ if (error instanceof GuardedDiscordDispatchError)
204
+ return {
205
+ kind: error.code === "selector_mismatch" ? "invalid-request" : "transient",
206
+ };
207
+ if (error instanceof DiscordAPIError) {
208
+ const status = error.status, code = error.code;
209
+ if (status === 404 && code === 10008)
210
+ return { kind: "already-absent" };
211
+ if (status === 403)
212
+ return { kind: "missing-permission" };
213
+ if (status === 401)
214
+ return failed;
215
+ if (Number.isInteger(status) && status >= 400 && status < 500)
216
+ return { kind: "invalid-request" };
217
+ return failed;
218
+ }
219
+ if (error instanceof HTTPError)
220
+ return {
221
+ kind: Number.isInteger(error.status) &&
222
+ error.status >= 500 &&
223
+ error.status < 600
224
+ ? "transient"
225
+ : "failed",
226
+ };
227
+ if (error instanceof RateLimitError) {
228
+ const delays = [
229
+ error.retryAfter,
230
+ error.sublimitTimeout,
231
+ error.timeToReset,
232
+ ];
233
+ if (typeof error.global !== "boolean" ||
234
+ delays.some((delay) => !Number.isFinite(delay) || delay < 0 || delay > 86_400_000))
235
+ return failed;
236
+ const afterMs = Math.max(...delays);
237
+ return afterMs > 0
238
+ ? { kind: "retry", afterMs, scope: error.global ? "global" : "route" }
239
+ : failed;
240
+ }
241
+ if (error instanceof Error) {
242
+ const code = Reflect.get(error, "code");
243
+ if (error.name === "AbortError" ||
244
+ (typeof code === "string" && networkCodes.has(code)))
245
+ return { kind: "transient" };
246
+ }
247
+ }
248
+ catch {
249
+ /* No raw provider fields escape this classifier. */
250
+ }
251
+ return failed;
252
+ }
253
+ //# sourceMappingURL=emergency-deletion-rest-transport.js.map
package/dist/events.d.ts CHANGED
@@ -3,10 +3,15 @@ import { inspectDiscordChannelAudit } from "./discord-channel-audit.js";
3
3
  import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
4
4
  import { type InteractionLogger } from "./interactions.js";
5
5
  import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
6
+ import type { EmergencyDeletionDispatcher } from "./emergency-deletion-dispatcher.js";
7
+ import { type MessageProtectionEligibility } from "./guild-message-protection-delivery.js";
6
8
  export declare function availabilityAwareInstallations(services: ServiceAccess, modulesById?: ReadonlyMap<string, ModuleDefinition>): Pick<InstallationService, "isModuleEnabled">;
7
9
  export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess, options?: {
8
10
  runtimeProfile?: "hosted" | "self-hosted";
9
11
  evidenceStore?: TransientViolationEvidenceStore;
12
+ messageProtectionEligibility?: MessageProtectionEligibility;
13
+ messageProtectionDeletion?: EmergencyDeletionDispatcher;
14
+ messageProtectionExpectedApplicationId?: string;
10
15
  }): ModuleEventHandle;
11
16
  export interface ModuleEventHandle {
12
17
  stop(): Promise<void>;
package/dist/events.js CHANGED
@@ -10,6 +10,9 @@ import { canAccessModule } from "./premium-access.js";
10
10
  import { fromDiscordAction, toRuleSnapshot, } from "./discord-auto-moderation-service.js";
11
11
  import { PrivilegedMessageMetadataCache } from "./privileged-message-metadata-cache.js";
12
12
  import { DiscordAutoModerationExecutionIds } from "./discord-automod-execution-ids.js";
13
+ import { createMessageProtectionAuthorityFactory } from "./guild-message-protection-deletion.js";
14
+ import { GuildMessageUpdateMetadataDelivery } from "./guild-message-update-metadata.js";
15
+ import { GuildMessageProtectionDelivery, } from "./guild-message-protection-delivery.js";
13
16
  export function availabilityAwareInstallations(services, modulesById) {
14
17
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
15
18
  const candidate = typeof services.has === "function" &&
@@ -90,6 +93,7 @@ export function installModuleEvents(client, modules, logger, services, options)
90
93
  moduleId: module.manifest.id,
91
94
  classificationRequested: module.requiredDiscordCapabilities?.includes("message-content") ??
92
95
  false,
96
+ deletionAllowed: module.manifest.permissions?.includes("MANAGE_MESSAGES") ?? false,
93
97
  contribution,
94
98
  })));
95
99
  const guildVoiceSessionContributions = modules.flatMap((module) => (module.events?.guildVoiceSession ?? []).map((contribution) => ({
@@ -99,6 +103,9 @@ export function installModuleEvents(client, modules, logger, services, options)
99
103
  const messageClassificationRequested = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
100
104
  const messageClassificationAvailable = messageClassificationRequested &&
101
105
  client.options.intents.has(GatewayIntentBits.MessageContent);
106
+ const protectionDelivery = new GuildMessageProtectionDelivery(options?.messageProtectionEligibility, services, messageClassificationAvailable, () => client.user?.id, () => client.application?.id, options?.messageProtectionDeletion, createMessageProtectionAuthorityFactory(client, options?.messageProtectionExpectedApplicationId, options?.messageProtectionEligibility, options?.messageProtectionDeletion));
107
+ const legacyMessageContributions = guildMessageContributions.filter(({ contribution }) => !contribution.protection);
108
+ const protectionMessageContributions = guildMessageContributions.filter(({ contribution }) => contribution.protection);
102
109
  const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
103
110
  moduleId: module.manifest.id,
104
111
  contribution,
@@ -119,6 +126,11 @@ export function installModuleEvents(client, modules, logger, services, options)
119
126
  moduleId: module.manifest.id,
120
127
  contribution,
121
128
  })));
129
+ const messageUpdateMetadataContributions = modules.flatMap((module) => (module.events?.guildMessageUpdateMetadata ?? []).map((contribution) => ({
130
+ moduleId: module.manifest.id,
131
+ contribution,
132
+ })));
133
+ const updateMetadataDelivery = new GuildMessageUpdateMetadataDelivery(client, options?.messageProtectionExpectedApplicationId, options?.messageProtectionEligibility);
122
134
  const messageDeleteContributions = modules.flatMap((module) => (module.events?.messageDelete ?? []).map((contribution) => ({
123
135
  moduleId: module.manifest.id,
124
136
  contribution,
@@ -172,6 +184,7 @@ export function installModuleEvents(client, modules, logger, services, options)
172
184
  reactionRemoveAllContributions.length === 0 &&
173
185
  reactionRemoveEmojiContributions.length === 0 &&
174
186
  messageUpdateContributions.length === 0 &&
187
+ messageUpdateMetadataContributions.length === 0 &&
175
188
  messageDeleteContributions.length === 0 &&
176
189
  messageDeleteBulkContributions.length === 0 &&
177
190
  memberAddContributions.length === 0 &&
@@ -184,6 +197,14 @@ export function installModuleEvents(client, modules, logger, services, options)
184
197
  guildEmojiUpdateContributions.length === 0)
185
198
  return { stop: () => Promise.resolve(), queueDepth: () => 0 };
186
199
  const queue = new BoundedGuildEventQueue(logger);
200
+ const updateMetadataQueue = messageUpdateMetadataContributions.length
201
+ ? new BoundedGuildEventQueue(logger)
202
+ : undefined;
203
+ const updateMetadataError = (moduleId, error) => logger.warn({
204
+ event: "module.message_update_metadata_failed",
205
+ moduleId,
206
+ error: safeError(error),
207
+ }, "Module update metadata event failed");
187
208
  const privilegedMetadata = new PrivilegedMessageMetadataCache();
188
209
  const voiceSessions = new Map();
189
210
  const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
@@ -272,15 +293,25 @@ export function installModuleEvents(client, modules, logger, services, options)
272
293
  }
273
294
  if (guildMessageContributions.length > 0) {
274
295
  client.on(Events.MessageCreate, (message) => {
275
- if (!message.guildId ||
276
- !guildMessageQueue ||
277
- message.author.bot ||
278
- message.author.system ||
279
- Boolean(message.webhookId) ||
280
- message.system) {
296
+ if (!message.guildId || !guildMessageQueue)
281
297
  return;
282
- }
283
- guildMessageQueue.enqueue(message.guildId, () => handleGuildMessageCreate(message));
298
+ const protection = protectionDelivery.select(message, protectionMessageContributions);
299
+ const legacy = legacyMessageContributions.length > 0 &&
300
+ !message.author.bot &&
301
+ !message.author.system &&
302
+ !message.webhookId &&
303
+ !message.system;
304
+ if (!legacy && protection.length === 0)
305
+ return;
306
+ guildMessageQueue.enqueue(message.guildId, async () => {
307
+ await protectionDelivery.execute(message, protection, (moduleId, error) => logger.warn({
308
+ event: "module.guild_message_protection_failed",
309
+ moduleId,
310
+ error: safeError(error),
311
+ }, "Module protection event failed"));
312
+ if (legacy)
313
+ await handleGuildMessageCreate(message);
314
+ });
284
315
  });
285
316
  }
286
317
  if (guildVoiceSessionContributions.length > 0) {
@@ -323,6 +354,13 @@ export function installModuleEvents(client, modules, logger, services, options)
323
354
  queue.enqueue(message.guildId, () => handleMessageUpdate(message));
324
355
  });
325
356
  }
357
+ if (updateMetadataQueue) {
358
+ client.on(Events.MessageUpdate, (_before, message) => {
359
+ const prepared = updateMetadataDelivery.prepare(message, messageUpdateMetadataContributions, updateMetadataError);
360
+ if (prepared)
361
+ updateMetadataQueue.enqueue(prepared.guildId, prepared.run);
362
+ });
363
+ }
326
364
  if (privilegedMessageUpdateContributions.length > 0) {
327
365
  client.on(Events.MessageUpdate, (_before, message) => {
328
366
  if (message.guildId)
@@ -614,7 +652,7 @@ export function installModuleEvents(client, modules, logger, services, options)
614
652
  message.author.username;
615
653
  const mentionedUserIds = [...message.mentions.users.keys()].slice(0, 25);
616
654
  let classifiedMessage;
617
- for (const { moduleId, classificationRequested, contribution, } of guildMessageContributions) {
655
+ for (const { moduleId, classificationRequested, contribution, } of legacyMessageContributions) {
618
656
  try {
619
657
  if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
620
658
  continue;
@@ -1095,15 +1133,20 @@ export function installModuleEvents(client, modules, logger, services, options)
1095
1133
  }
1096
1134
  return {
1097
1135
  stop: async () => {
1136
+ updateMetadataDelivery.stop();
1137
+ protectionDelivery.stop();
1138
+ options?.messageProtectionDeletion?.stop();
1098
1139
  await privilegedEligibilityQueue?.stop();
1099
1140
  await guildMessageQueue?.stop();
1141
+ await updateMetadataQueue?.stop();
1100
1142
  await queue.stop();
1101
1143
  voiceSessions.clear();
1102
1144
  privilegedMetadata.clear();
1103
1145
  },
1104
1146
  queueDepth: () => queue.queueDepth() +
1105
1147
  (privilegedEligibilityQueue?.queueDepth() ?? 0) +
1106
- (guildMessageQueue?.queueDepth() ?? 0),
1148
+ (guildMessageQueue?.queueDepth() ?? 0) +
1149
+ (updateMetadataQueue?.queueDepth() ?? 0),
1107
1150
  };
1108
1151
  }
1109
1152
  class BoundedGuildEventQueue {
@@ -0,0 +1,29 @@
1
+ import { Agent, type Dispatcher } from "undici";
2
+ type Refusal = "selector_mismatch" | "stopped" | "revoked" | "expired" | "clock_invalid" | "budget_exhausted";
3
+ /** Private refusal, deliberately outside the REST AbortError/ECONNRESET retry classifier. */
4
+ export declare class GuardedDiscordDispatchError extends Error {
5
+ readonly code: Refusal;
6
+ constructor(code: Refusal);
7
+ }
8
+ interface Options {
9
+ agent: Agent;
10
+ origin: string;
11
+ path: string;
12
+ method: "DELETE";
13
+ isCurrent(this: void): boolean;
14
+ maximumAgeMs: number;
15
+ maximumDispatches: number;
16
+ now?: () => number;
17
+ }
18
+ /** Dormant exact-request facade. It delegates connections and their ownership to the supplied agent. */
19
+ export declare class GuardedDiscordDispatcher extends Agent {
20
+ #private;
21
+ constructor(options: Options);
22
+ /** Admissions immediately before socket dispatch, not confirmed HTTP deliveries or outer queue attempts. */
23
+ get admittedConnects(): number;
24
+ /** Does not close or destroy the shared agent, and cannot recall requests already dispatched. */
25
+ stop(): void;
26
+ dispatch(options: Agent.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean;
27
+ }
28
+ export {};
29
+ //# sourceMappingURL=guarded-discord-dispatcher.d.ts.map
@@ -0,0 +1,204 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { Agent } from "undici";
3
+ /** Private refusal, deliberately outside the REST AbortError/ECONNRESET retry classifier. */
4
+ export class GuardedDiscordDispatchError extends Error {
5
+ code;
6
+ constructor(code) {
7
+ super("Guarded Discord dispatch refused");
8
+ this.name = "GuardedDiscordDispatchError";
9
+ this.code = code;
10
+ }
11
+ }
12
+ /** Dormant exact-request facade. It delegates connections and their ownership to the supplied agent. */
13
+ export class GuardedDiscordDispatcher extends Agent {
14
+ #agent;
15
+ #origin;
16
+ #path;
17
+ #isCurrent;
18
+ #now;
19
+ #acceptedAt;
20
+ #maximumAgeMs;
21
+ #maximumDispatches;
22
+ #lastNow;
23
+ #admittedConnects = 0;
24
+ #refused;
25
+ constructor(options) {
26
+ super({
27
+ factory() {
28
+ throw new Error("Guarded facade must not create a connection pool");
29
+ },
30
+ });
31
+ const origin = canonicalOrigin(options.origin);
32
+ if (!(options.agent instanceof Agent) ||
33
+ !origin ||
34
+ options.method !== "DELETE" ||
35
+ !/^\/api\/v[1-9]\d?\/channels\/\d{17,20}\/messages\/\d{17,20}$/u.test(options.path) ||
36
+ typeof options.isCurrent !== "function" ||
37
+ (options.now !== undefined && typeof options.now !== "function") ||
38
+ !Number.isSafeInteger(options.maximumAgeMs) ||
39
+ options.maximumAgeMs < 1 ||
40
+ options.maximumAgeMs > 600_000 ||
41
+ !Number.isSafeInteger(options.maximumDispatches) ||
42
+ options.maximumDispatches < 1 ||
43
+ options.maximumDispatches > 16)
44
+ throw new TypeError("Invalid guarded Discord dispatcher options");
45
+ this.#agent = options.agent;
46
+ this.#origin = origin;
47
+ this.#path = options.path;
48
+ this.#isCurrent = options.isCurrent;
49
+ this.#maximumAgeMs = options.maximumAgeMs;
50
+ this.#maximumDispatches = options.maximumDispatches;
51
+ this.#now = options.now ?? (() => performance.now());
52
+ const now = this.#now();
53
+ if (!Number.isFinite(now) ||
54
+ now < 0 ||
55
+ now > Number.MAX_SAFE_INTEGER - this.#maximumAgeMs)
56
+ throw new TypeError("Invalid guarded Discord dispatcher clock");
57
+ this.#acceptedAt = this.#lastNow = now;
58
+ }
59
+ /** Admissions immediately before socket dispatch, not confirmed HTTP deliveries or outer queue attempts. */
60
+ get admittedConnects() {
61
+ return this.#admittedConnects;
62
+ }
63
+ /** Does not close or destroy the shared agent, and cannot recall requests already dispatched. */
64
+ stop() {
65
+ this.#refused ??= "stopped";
66
+ }
67
+ dispatch(options, handler) {
68
+ const snapshot = { ...options };
69
+ const captured = Object.freeze({
70
+ ...snapshot,
71
+ origin: canonicalOrigin(snapshot.origin) ?? "",
72
+ });
73
+ const failure = this.#matches(captured)
74
+ ? this.#check()
75
+ : "selector_mismatch";
76
+ if (failure) {
77
+ const error = new GuardedDiscordDispatchError(failure);
78
+ if (!handler.onError)
79
+ throw error;
80
+ handler.onError(error);
81
+ return false;
82
+ }
83
+ const guarded = new Proxy({}, {
84
+ get: (_facade, key) => {
85
+ const target = handler;
86
+ if (key === "onConnect")
87
+ return (abort, ...arguments_) => {
88
+ const failure = this.#check();
89
+ if (failure) {
90
+ abort(new GuardedDiscordDispatchError(failure));
91
+ return;
92
+ }
93
+ const callback = Reflect.get(target, key, target);
94
+ const beforeCallback = this.#check();
95
+ if (beforeCallback) {
96
+ abort(new GuardedDiscordDispatchError(beforeCallback));
97
+ return;
98
+ }
99
+ const result = typeof callback === "function"
100
+ ? Reflect.apply(callback, target, [abort, ...arguments_])
101
+ : undefined;
102
+ const finalFailure = this.#check();
103
+ if (finalFailure) {
104
+ abort(new GuardedDiscordDispatchError(finalFailure));
105
+ return;
106
+ }
107
+ this.#admittedConnects++;
108
+ return result;
109
+ };
110
+ const value = Reflect.get(target, key, target);
111
+ const forwarded = typeof value === "function" ? value.bind(target) : value;
112
+ return forwarded;
113
+ },
114
+ });
115
+ return this.#agent.dispatch(captured, guarded);
116
+ }
117
+ #matches(options) {
118
+ return (options.method === "DELETE" &&
119
+ canonicalOrigin(options.origin) === this.#origin &&
120
+ options.path === this.#path &&
121
+ options.query === undefined &&
122
+ (options.body === undefined || options.body === null) &&
123
+ (options.upgrade === undefined ||
124
+ options.upgrade === null ||
125
+ options.upgrade === false));
126
+ }
127
+ #check() {
128
+ const terminal = this.#terminal();
129
+ if (terminal)
130
+ return terminal;
131
+ const clockFailure = this.#checkClock();
132
+ if (clockFailure)
133
+ return clockFailure;
134
+ if (this.#admittedConnects >= this.#maximumDispatches)
135
+ return this.#refuse("budget_exhausted");
136
+ try {
137
+ const current = this.#isCurrent();
138
+ const afterAuthority = this.#terminal();
139
+ if (afterAuthority)
140
+ return afterAuthority;
141
+ if (current !== true)
142
+ return this.#refuse("revoked");
143
+ }
144
+ catch {
145
+ return this.#refuse("revoked");
146
+ }
147
+ const finalClockFailure = this.#checkClock();
148
+ if (finalClockFailure)
149
+ return finalClockFailure;
150
+ if (this.#admittedConnects >= this.#maximumDispatches)
151
+ return this.#refuse("budget_exhausted");
152
+ return undefined;
153
+ }
154
+ #checkClock() {
155
+ let now;
156
+ try {
157
+ now = this.#now();
158
+ }
159
+ catch {
160
+ return this.#refuse("clock_invalid");
161
+ }
162
+ const afterClock = this.#terminal();
163
+ if (afterClock)
164
+ return afterClock;
165
+ if (!Number.isFinite(now) ||
166
+ now < this.#lastNow ||
167
+ now > Number.MAX_SAFE_INTEGER)
168
+ return this.#refuse("clock_invalid");
169
+ this.#lastNow = now;
170
+ if (now - this.#acceptedAt >= this.#maximumAgeMs)
171
+ return this.#refuse("expired");
172
+ return undefined;
173
+ }
174
+ #terminal() {
175
+ if (this.#refused)
176
+ return this.#refused;
177
+ if (this.closed || this.destroyed)
178
+ return this.#refuse("stopped");
179
+ return undefined;
180
+ }
181
+ #refuse(code) {
182
+ this.#refused ??= code;
183
+ return this.#refused;
184
+ }
185
+ }
186
+ function canonicalOrigin(input) {
187
+ if (!(typeof input === "string" || input instanceof URL))
188
+ return undefined;
189
+ try {
190
+ const url = new URL(input);
191
+ if (!/^https?:$/u.test(url.protocol) ||
192
+ url.username ||
193
+ url.password ||
194
+ url.pathname !== "/" ||
195
+ url.search ||
196
+ url.hash)
197
+ return undefined;
198
+ return url.origin;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
204
+ //# sourceMappingURL=guarded-discord-dispatcher.js.map
@@ -4,6 +4,7 @@ export interface GuildCommandDiscordRest {
4
4
  put(route: `/${string}`, options: {
5
5
  body: unknown;
6
6
  }): Promise<unknown>;
7
+ delete(route: `/${string}`): Promise<unknown>;
7
8
  }
8
9
  export declare class GuildCommandDiscordClient {
9
10
  #private;
@@ -16,6 +17,7 @@ export declare class GuildCommandDiscordClient {
16
17
  });
17
18
  canWrite(guildId: string): boolean;
18
19
  observe(guildId: string): Promise<ObservedGuildCommand[]>;
20
+ remove(guildId: string, commandId: string): Promise<void>;
19
21
  replace(guildId: string, definitions: readonly ProjectedGuildCommand[]): Promise<ObservedGuildCommand[]>;
20
22
  }
21
23
  //# sourceMappingURL=guild-command-discord-client.d.ts.map
@@ -24,6 +24,13 @@ export class GuildCommandDiscordClient {
24
24
  validateSnowflake(guildId, "Guild ID");
25
25
  return parseCommandCollection(await this.#rest.get(Routes.applicationGuildCommands(this.#applicationId, guildId)));
26
26
  }
27
+ async remove(guildId, commandId) {
28
+ validateSnowflake(guildId, "Guild ID");
29
+ validateSnowflake(commandId, "Command ID");
30
+ if (!this.canWrite(guildId))
31
+ throw new Error("Guild command write target is not allowlisted");
32
+ await this.#rest.delete(Routes.applicationGuildCommand(this.#applicationId, guildId, commandId));
33
+ }
27
34
  async replace(guildId, definitions) {
28
35
  validateSnowflake(guildId, "Guild ID");
29
36
  if (!this.canWrite(guildId))