@helyx/bot 1.5.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/command-root-catalogue.json +452 -0
- package/dist/cached-message-deletion-authority.d.ts +27 -0
- package/dist/cached-message-deletion-authority.js +288 -0
- package/dist/channel-audit-configuration.d.ts +11 -1
- package/dist/channel-audit-configuration.js +28 -6
- package/dist/channel-audit-control.js +42 -2
- package/dist/channel-audit-permissions.js +76 -3
- package/dist/cloud-command-policy-sync.d.ts +26 -0
- package/dist/cloud-command-policy-sync.js +198 -0
- package/dist/cloud-connector.d.ts +6 -1
- package/dist/cloud-connector.js +224 -53
- package/dist/cloud-control-dispatcher.js +18 -0
- package/dist/command-policy-runtime.d.ts +23 -0
- package/dist/command-policy-runtime.js +53 -0
- package/dist/command-root-catalogue.d.ts +5 -0
- package/dist/command-root-catalogue.js +16 -0
- package/dist/control-error-mapping.d.ts +8 -0
- package/dist/control-error-mapping.js +60 -0
- package/dist/control-errors.d.ts +3 -1
- package/dist/control-errors.js +4 -1
- package/dist/control-health-routes.d.ts +10 -0
- package/dist/control-health-routes.js +28 -0
- package/dist/control-server-schemas.d.ts +7 -0
- package/dist/control-server-schemas.js +9 -0
- package/dist/control-server.d.ts +27 -6
- package/dist/control-server.js +103 -122
- package/dist/dashboard-feature-control.d.ts +2 -1
- package/dist/dashboard-feature-control.js +1 -0
- package/dist/dashboard-logging.d.ts +20 -0
- package/dist/dashboard-logging.js +39 -0
- package/dist/dashboard-module-projection.d.ts +1 -1
- package/dist/dashboard-module-projection.js +5 -2
- package/dist/dashboard-preview-import-routes.js +8 -0
- package/dist/database-bootstrap.d.ts +1 -1
- package/dist/database-bootstrap.js +4 -3
- package/dist/discord-channel-audit.js +1 -0
- package/dist/discord-user-identities.d.ts +14 -0
- package/dist/discord-user-identities.js +64 -0
- package/dist/emergency-deletion-dispatcher.d.ts +68 -0
- package/dist/emergency-deletion-dispatcher.js +502 -0
- package/dist/emergency-deletion-rest-transport.d.ts +24 -0
- package/dist/emergency-deletion-rest-transport.js +253 -0
- package/dist/events.d.ts +5 -0
- package/dist/events.js +53 -10
- package/dist/guarded-discord-dispatcher.d.ts +29 -0
- package/dist/guarded-discord-dispatcher.js +204 -0
- package/dist/guild-command-discord-client.d.ts +2 -0
- package/dist/guild-command-discord-client.js +7 -0
- package/dist/guild-command-projection.d.ts +4 -1
- package/dist/guild-command-projection.js +25 -0
- package/dist/guild-command-read-service.d.ts +10 -1
- package/dist/guild-command-read-service.js +37 -0
- package/dist/guild-command-worker.d.ts +19 -4
- package/dist/guild-command-worker.js +356 -26
- package/dist/guild-message-protection-author-roles.d.ts +7 -0
- package/dist/guild-message-protection-author-roles.js +93 -0
- package/dist/guild-message-protection-deletion.d.ts +21 -0
- package/dist/guild-message-protection-deletion.js +138 -0
- package/dist/guild-message-protection-delivery.d.ts +41 -0
- package/dist/guild-message-protection-delivery.js +223 -0
- package/dist/guild-message-update-metadata.d.ts +32 -0
- package/dist/guild-message-update-metadata.js +255 -0
- package/dist/guilds.d.ts +2 -1
- package/dist/guilds.js +11 -1
- package/dist/hosted-server-moderation.d.ts +2 -2
- package/dist/index.js +68 -4
- package/dist/interactions.d.ts +3 -1
- package/dist/interactions.js +77 -12
- package/dist/invite-rollup-maintenance.d.ts +9 -0
- package/dist/invite-rollup-maintenance.js +12 -0
- package/dist/managed-resource-authorization.d.ts +15 -0
- package/dist/managed-resource-authorization.js +37 -0
- package/dist/managed-resource-create-defaults.d.ts +21 -0
- package/dist/managed-resource-create-defaults.js +83 -0
- package/dist/managed-resource-query.d.ts +6 -0
- package/dist/managed-resource-query.js +15 -0
- package/dist/member-role-ids.d.ts +17 -0
- package/dist/member-role-ids.js +66 -0
- package/dist/message-protection-admission.d.ts +60 -0
- package/dist/message-protection-admission.js +279 -0
- package/dist/message-protection-cold-data.d.ts +42 -0
- package/dist/message-protection-cold-data.js +180 -0
- package/dist/message-protection-coordinator.d.ts +73 -0
- package/dist/message-protection-coordinator.js +622 -0
- package/dist/message-protection-settings.d.ts +40 -0
- package/dist/message-protection-settings.js +275 -0
- package/dist/message-protection-summary.d.ts +8 -0
- package/dist/message-protection-summary.js +98 -0
- package/dist/module-availability-control.js +20 -12
- package/dist/module-logging.d.ts +3 -2
- package/dist/module-logging.js +6 -1
- package/dist/modules.js +56 -1
- package/dist/mysql-runtime-persistence.d.ts +1 -0
- package/dist/mysql-runtime-persistence.js +4 -1
- package/dist/nexus-command-recovery-control-routes.d.ts +5 -0
- package/dist/nexus-command-recovery-control-routes.js +48 -0
- package/dist/nexus-command-references.d.ts +6 -0
- package/dist/nexus-command-references.js +33 -0
- package/dist/scheduled-task-runner.js +23 -4
- package/dist/shard-command-startup.d.ts +13 -0
- package/dist/shard-command-startup.js +27 -0
- package/dist/shard-contracts.d.ts +84 -0
- package/dist/shard-contracts.js +33 -0
- package/dist/shard-core-readiness.d.ts +5 -0
- package/dist/shard-core-readiness.js +19 -0
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +10 -1
- package/dist/shard-supervisor.d.ts +1 -1
- package/dist/shard-supervisor.js +124 -43
- package/dist/shard-worker.js +25 -15
- package/documentation/README.md +1 -0
- package/documentation/examples/.env.example +0 -45
- package/documentation/guides/self-hosting.md +54 -0
- 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
|
-
|
|
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
|
|
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))
|