@helyx/bot 1.5.0 → 1.5.2
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 +19 -0
- package/dist/command-registration-policy.js +1 -0
- package/dist/creator-invite-key.d.ts +6 -0
- package/dist/creator-invite-key.js +16 -0
- package/dist/creator-invite-service.d.ts +3 -1
- package/dist/creator-invite-service.js +8 -7
- package/dist/creator-onboarding-platform.d.ts +1 -0
- package/dist/creator-onboarding-platform.js +5 -2
- package/dist/diagnostic-command.d.ts +76 -0
- package/dist/diagnostic-command.js +1962 -0
- package/dist/index.js +30 -1
- package/dist/shard-supervisor.js +14 -0
- package/dist/shard-worker.d.ts +2 -0
- package/dist/shard-worker.js +50 -1
- package/documentation/reference/environment.md +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1962 @@
|
|
|
1
|
+
import { guildCommandDefinitionHash, } from "./guild-command-projection.js";
|
|
2
|
+
import { canUseCommand, hasHelyxAccessLevel } from "@helyx/core";
|
|
3
|
+
import { isCustomerSelectableModule, } from "@helyx/module-manifest";
|
|
4
|
+
import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
5
|
+
import { ChannelType, PermissionsBitField, DiscordAPIError, Routes, GatewayIntentBits, PermissionFlagsBits, } from "discord.js";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
9
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
10
|
+
import { canAccessModule } from "./premium-access.js";
|
|
11
|
+
const DIAGNOSTIC_MODULE_ID = "helyx.diagnostics";
|
|
12
|
+
const RED = 0xdc2626;
|
|
13
|
+
const ORANGE = 0xf59e0b;
|
|
14
|
+
const GREEN = 0x16a34a;
|
|
15
|
+
const MIN_STAGE_MS = 1_500;
|
|
16
|
+
const MAX_RUN_MS = 10 * 60_000;
|
|
17
|
+
const MAX_PROBES = 500;
|
|
18
|
+
const CORE_MODULE_IDS = new Set([
|
|
19
|
+
DIAGNOSTIC_MODULE_ID,
|
|
20
|
+
"helyx.management",
|
|
21
|
+
"helyx.permissions",
|
|
22
|
+
"helyx.support",
|
|
23
|
+
"helyx.oauth",
|
|
24
|
+
"helyx.privacy",
|
|
25
|
+
"helyx.cloud-connect",
|
|
26
|
+
"helyx.premium-activation",
|
|
27
|
+
"helyx.developer-operations",
|
|
28
|
+
]);
|
|
29
|
+
class DiagnosticInterrupted extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** A core command, never a selectable module or a module-repair hook. */
|
|
32
|
+
export function createDiagnosticModule(options) {
|
|
33
|
+
const active = new Set();
|
|
34
|
+
const localGuilds = new Set();
|
|
35
|
+
const shutdown = new AbortController();
|
|
36
|
+
let stopping = false;
|
|
37
|
+
const administratorPolicy = {
|
|
38
|
+
everyone: false,
|
|
39
|
+
accessLevel: "administrator",
|
|
40
|
+
requiredPermissions: [],
|
|
41
|
+
allowedRoleIds: [],
|
|
42
|
+
deniedRoleIds: [],
|
|
43
|
+
allowedUserIds: [],
|
|
44
|
+
deniedUserIds: [],
|
|
45
|
+
};
|
|
46
|
+
return defineModule({
|
|
47
|
+
manifest: {
|
|
48
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
49
|
+
id: DIAGNOSTIC_MODULE_ID,
|
|
50
|
+
name: "Helyx Diagnostics",
|
|
51
|
+
version: "0.1.0",
|
|
52
|
+
releaseStatus: "live",
|
|
53
|
+
description: "Checks this server and verifies supported repairs.",
|
|
54
|
+
entry: "./dist/diagnostic-command.js",
|
|
55
|
+
framework: "^0.1.0",
|
|
56
|
+
dependencies: [],
|
|
57
|
+
permissions: [],
|
|
58
|
+
contributions: { commands: ["diag"], events: [] },
|
|
59
|
+
},
|
|
60
|
+
defaultPermissions: {
|
|
61
|
+
module: administratorPolicy,
|
|
62
|
+
commands: { diag: administratorPolicy },
|
|
63
|
+
},
|
|
64
|
+
interactions: {
|
|
65
|
+
commands: [
|
|
66
|
+
{
|
|
67
|
+
name: "diag",
|
|
68
|
+
permissionId: "diag",
|
|
69
|
+
description: "Check Helyx modules and repair supported problems",
|
|
70
|
+
registrationPolicy: "platform-global",
|
|
71
|
+
contexts: ["guild"],
|
|
72
|
+
integrationTypes: ["guild_install"],
|
|
73
|
+
authorization: "self-authorized",
|
|
74
|
+
acknowledgement: "immediate-response",
|
|
75
|
+
options: [
|
|
76
|
+
{
|
|
77
|
+
name: "ephemeral",
|
|
78
|
+
description: "Keep the whole report private",
|
|
79
|
+
type: "boolean",
|
|
80
|
+
required: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
async execute(context) {
|
|
84
|
+
const ephemeral = context.getBooleanOption("ephemeral") ?? false;
|
|
85
|
+
// Visibility is selected before any database or Discord work.
|
|
86
|
+
if (context.deferReply)
|
|
87
|
+
await context.deferReply(ephemeral);
|
|
88
|
+
else
|
|
89
|
+
await context.reply(report(ephemeral, ORANGE, ["Starting server checks..."]));
|
|
90
|
+
const guildId = context.serverId;
|
|
91
|
+
if (!guildId) {
|
|
92
|
+
await context.reply(report(ephemeral, ORANGE, ["Use /diag in a Discord server."]));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (stopping ||
|
|
96
|
+
!options.runExclusive ||
|
|
97
|
+
!options.assertGuildAvailable ||
|
|
98
|
+
(!options.client && !options.resources)) {
|
|
99
|
+
await context.reply(report(ephemeral, RED, [
|
|
100
|
+
"Diagnostics is unavailable on this bot. Ask the operator to check its setup.",
|
|
101
|
+
]));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const resources = options.resources?.(guildId) ??
|
|
105
|
+
createNativeDiagnosticResources(options.client, guildId);
|
|
106
|
+
resourceGuildIds.set(resources, guildId);
|
|
107
|
+
try {
|
|
108
|
+
await assertAdministrator(context.services, resources, context.userId);
|
|
109
|
+
await options.assertGuildAvailable(guildId);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
await context.reply(report(ephemeral, ORANGE, [
|
|
113
|
+
"Only the server owner, Discord Administrators and Helyx Administrators can run /diag on an active server.",
|
|
114
|
+
]));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (localGuilds.has(guildId)) {
|
|
118
|
+
await context.reply(report(ephemeral, ORANGE, [
|
|
119
|
+
"A diagnostic run is already in progress for this server.",
|
|
120
|
+
]));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
localGuilds.add(guildId);
|
|
124
|
+
const operation = (async () => {
|
|
125
|
+
try {
|
|
126
|
+
const acquired = await options.runExclusive(guildId, async (lease) => {
|
|
127
|
+
const timeout = AbortSignal.timeout(options.maximumRunMilliseconds ?? MAX_RUN_MS);
|
|
128
|
+
const signal = AbortSignal.any([
|
|
129
|
+
shutdown.signal,
|
|
130
|
+
lease.signal,
|
|
131
|
+
timeout,
|
|
132
|
+
]);
|
|
133
|
+
await executeDiagnostic({
|
|
134
|
+
context,
|
|
135
|
+
options,
|
|
136
|
+
resources,
|
|
137
|
+
lease,
|
|
138
|
+
signal,
|
|
139
|
+
ephemeral,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
if (!acquired)
|
|
143
|
+
await context.reply(report(ephemeral, ORANGE, [
|
|
144
|
+
"A diagnostic run is already in progress for this server.",
|
|
145
|
+
]));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
if (!shutdown.signal.aborted) {
|
|
149
|
+
// No raw errors, resource IDs or private configuration enter a public report.
|
|
150
|
+
await context
|
|
151
|
+
.reply(report(ephemeral, RED, [
|
|
152
|
+
"The diagnostic run could not finish.",
|
|
153
|
+
"No further repairs will be attempted. Use /helyx bug if the problem continues.",
|
|
154
|
+
]))
|
|
155
|
+
.catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
localGuilds.delete(guildId);
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
active.add(operation);
|
|
163
|
+
try {
|
|
164
|
+
await operation;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
active.delete(operation);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
start() {
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
},
|
|
176
|
+
async stop() {
|
|
177
|
+
stopping = true;
|
|
178
|
+
shutdown.abort();
|
|
179
|
+
await Promise.allSettled([...active]);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function assertAdministrator(services, resources, userId) {
|
|
184
|
+
const actor = await resources.actor(userId);
|
|
185
|
+
const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
186
|
+
const guildId = diagnosticGuildId(resources);
|
|
187
|
+
const policy = permissions.getGuildAccessPolicy && guildId
|
|
188
|
+
? await permissions.getGuildAccessPolicy(guildId)
|
|
189
|
+
: undefined;
|
|
190
|
+
if (!hasHelyxAccessLevel(actor, "administrator", policy))
|
|
191
|
+
throw new DiagnosticInterrupted("authority_changed");
|
|
192
|
+
}
|
|
193
|
+
// Resource implementations carry their server scope without exposing it in reports.
|
|
194
|
+
const resourceGuildIds = new WeakMap();
|
|
195
|
+
function diagnosticGuildId(resources) {
|
|
196
|
+
return resourceGuildIds.get(resources);
|
|
197
|
+
}
|
|
198
|
+
async function executeDiagnostic(input) {
|
|
199
|
+
const { context, options, resources, lease, signal, ephemeral } = input;
|
|
200
|
+
const guildId = context.serverId;
|
|
201
|
+
resourceGuildIds.set(resources, guildId);
|
|
202
|
+
const started = performance.now();
|
|
203
|
+
const stageMs = options.stageMilliseconds ?? MIN_STAGE_MS;
|
|
204
|
+
const observations = new Map();
|
|
205
|
+
let checked = 0;
|
|
206
|
+
let expected = 0;
|
|
207
|
+
let customerCount = 0;
|
|
208
|
+
let originalEnabledIds = [];
|
|
209
|
+
let deliveryLost = false;
|
|
210
|
+
const assertRunning = async () => {
|
|
211
|
+
if (signal.aborted)
|
|
212
|
+
throw new DiagnosticInterrupted("run_stopped");
|
|
213
|
+
await lease.assertOwned();
|
|
214
|
+
await options.assertGuildAvailable(guildId);
|
|
215
|
+
if (signal.aborted)
|
|
216
|
+
throw new DiagnosticInterrupted("run_stopped");
|
|
217
|
+
};
|
|
218
|
+
const show = async (lines, color = currentColor(observations)) => {
|
|
219
|
+
if (deliveryLost)
|
|
220
|
+
throw new DiagnosticInterrupted("report_unavailable");
|
|
221
|
+
try {
|
|
222
|
+
await context.reply(report(ephemeral, color, lines));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
deliveryLost = true;
|
|
226
|
+
throw new DiagnosticInterrupted("report_unavailable");
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const stage = async (lines, work) => {
|
|
230
|
+
await assertRunning();
|
|
231
|
+
const stageStarted = performance.now();
|
|
232
|
+
await show(lines);
|
|
233
|
+
const result = await work();
|
|
234
|
+
if (signal.aborted)
|
|
235
|
+
throw new DiagnosticInterrupted("run_stopped");
|
|
236
|
+
const remaining = stageMs - (performance.now() - stageStarted);
|
|
237
|
+
if (remaining > 0)
|
|
238
|
+
await delay(remaining, undefined, { signal });
|
|
239
|
+
return result;
|
|
240
|
+
};
|
|
241
|
+
const installations = context.services.get(HELYX_SERVICE_NAMES.installations);
|
|
242
|
+
const configuration = context.services.get(HELYX_SERVICE_NAMES.configuration);
|
|
243
|
+
const frozen = new Map();
|
|
244
|
+
try {
|
|
245
|
+
const discovered = await stage(["Scanning for active modules..."], async () => {
|
|
246
|
+
const loaded = new Map(options.modules.map((m) => [m.manifest.id, m]));
|
|
247
|
+
const enabled = options.listEnabledModules
|
|
248
|
+
? await options.listEnabledModules(guildId)
|
|
249
|
+
: (await Promise.all(options.modules.map(async (module) => ({
|
|
250
|
+
moduleId: module.manifest.id,
|
|
251
|
+
enabled: await installations.isModuleEnabled(guildId, module.manifest.id),
|
|
252
|
+
})))).filter((x) => x.enabled);
|
|
253
|
+
const missing = enabled.filter((x) => !loaded.has(x.moduleId) && !CORE_MODULE_IDS.has(x.moduleId));
|
|
254
|
+
originalEnabledIds = [
|
|
255
|
+
...new Set(enabled.map((x) => x.moduleId)),
|
|
256
|
+
].sort();
|
|
257
|
+
const selected = enabled
|
|
258
|
+
.map((x) => loaded.get(x.moduleId))
|
|
259
|
+
.filter((m) => !!m && isCustomerSelectableModule(m.manifest));
|
|
260
|
+
customerCount = selected.length;
|
|
261
|
+
const visit = async (module, customer) => {
|
|
262
|
+
const existing = frozen.get(module.manifest.id);
|
|
263
|
+
if (existing) {
|
|
264
|
+
if (customer)
|
|
265
|
+
existing.customer = true;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const state = await installations.getModuleState(guildId, module.manifest.id);
|
|
269
|
+
frozen.set(module.manifest.id, {
|
|
270
|
+
module,
|
|
271
|
+
customer,
|
|
272
|
+
enabledAt: state?.enabled ? state.enabledAt.getTime() : null,
|
|
273
|
+
configuration: await configuration.get(guildId, module.manifest.id),
|
|
274
|
+
});
|
|
275
|
+
for (const dependency of module.manifest.dependencies) {
|
|
276
|
+
const definition = loaded.get(dependency.id);
|
|
277
|
+
if (definition)
|
|
278
|
+
await visit(definition, false);
|
|
279
|
+
else
|
|
280
|
+
missing.push({ moduleId: dependency.id });
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
for (const module of selected)
|
|
284
|
+
await visit(module, true);
|
|
285
|
+
return { missing: [...new Set(missing.map((x) => x.moduleId))] };
|
|
286
|
+
});
|
|
287
|
+
await stage([
|
|
288
|
+
`${customerCount} active modules found.`,
|
|
289
|
+
"Checking setup, permissions and supported saved records.",
|
|
290
|
+
], () => Promise.resolve(undefined));
|
|
291
|
+
const assertCanMutate = async (moduleId) => {
|
|
292
|
+
await assertRunning();
|
|
293
|
+
await assertAdministrator(context.services, resources, context.userId);
|
|
294
|
+
const entry = frozen.get(moduleId);
|
|
295
|
+
if (!entry?.customer)
|
|
296
|
+
throw new DiagnosticInterrupted("scope_changed");
|
|
297
|
+
const current = await installations.getModuleState(guildId, moduleId);
|
|
298
|
+
if (!current?.enabled || current.enabledAt.getTime() !== entry.enabledAt)
|
|
299
|
+
throw new DiagnosticInterrupted("module_changed");
|
|
300
|
+
const config = await configuration.get(guildId, moduleId);
|
|
301
|
+
if (!sameConfiguration(entry.configuration, config))
|
|
302
|
+
throw new DiagnosticInterrupted("configuration_changed");
|
|
303
|
+
if (!context.services.has(HELYX_SERVICE_NAMES.moduleAvailability) ||
|
|
304
|
+
!(await context.services
|
|
305
|
+
.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
306
|
+
.isAvailable(moduleId)))
|
|
307
|
+
throw new DiagnosticInterrupted("availability_changed");
|
|
308
|
+
if (entry.module.configuration) {
|
|
309
|
+
if (entry.configuration &&
|
|
310
|
+
entry.configuration.schemaVersion !==
|
|
311
|
+
entry.module.configuration.schemaVersion)
|
|
312
|
+
throw new DiagnosticInterrupted("configuration_schema_changed");
|
|
313
|
+
try {
|
|
314
|
+
entry.module.configuration.validate(structuredClone(entry.configuration?.value ?? {}));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw new DiagnosticInterrupted("configuration_invalid");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!(await canAccessModule(context.services, guildId, entry.module)))
|
|
321
|
+
throw new DiagnosticInterrupted("access_changed");
|
|
322
|
+
await assertRunning();
|
|
323
|
+
};
|
|
324
|
+
const probes = [];
|
|
325
|
+
probes.push({
|
|
326
|
+
id: "scope.current",
|
|
327
|
+
label: "Current module scope",
|
|
328
|
+
inspect: async () => {
|
|
329
|
+
if (options.listEnabledModules) {
|
|
330
|
+
const current = [
|
|
331
|
+
...new Set((await options.listEnabledModules(guildId)).map((x) => x.moduleId)),
|
|
332
|
+
].sort();
|
|
333
|
+
if (!isDeepStrictEqual(current, originalEnabledIds))
|
|
334
|
+
return {
|
|
335
|
+
kind: "incomplete",
|
|
336
|
+
message: "The enabled modules changed during this run. Run /diag again to check the current selection.",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
for (const entry of frozen.values()) {
|
|
340
|
+
if (!entry.customer)
|
|
341
|
+
continue;
|
|
342
|
+
const current = await installations.getModuleState(guildId, entry.module.manifest.id);
|
|
343
|
+
if (!current?.enabled ||
|
|
344
|
+
current.enabledAt.getTime() !== entry.enabledAt)
|
|
345
|
+
return {
|
|
346
|
+
kind: "incomplete",
|
|
347
|
+
message: "A module was disabled or restarted during this run. Run /diag again to check its current state.",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
for (const id of discovered.missing) {
|
|
354
|
+
probes.push(fixedIssue(`package:${id}`, "Installed module package", "incomplete", "An enabled module or required shared service is not loaded. Ask the operator to check installed packages."));
|
|
355
|
+
}
|
|
356
|
+
for (const entry of frozen.values())
|
|
357
|
+
probes.push(...moduleProbes(entry, context.services, guildId, resources));
|
|
358
|
+
probes.push(...coreProbes(options, context, resources, assertRunning));
|
|
359
|
+
probes.push(...permissionProbes([...frozen.values()], context.services, guildId));
|
|
360
|
+
if (context.services.has(HELYX_SERVICE_NAMES.records)) {
|
|
361
|
+
const recordProbes = await stage(["Inspecting this server's declared saved records..."], () => createRecordProbes({
|
|
362
|
+
guildId,
|
|
363
|
+
actorUserId: context.userId,
|
|
364
|
+
correlationId: context.interactionId,
|
|
365
|
+
modules: [...frozen.values()]
|
|
366
|
+
.filter((x) => x.customer)
|
|
367
|
+
.map((x) => x.module),
|
|
368
|
+
records: context.services.get(HELYX_SERVICE_NAMES.records),
|
|
369
|
+
services: context.services,
|
|
370
|
+
assertCanInspect: assertRunning,
|
|
371
|
+
assertCanMutate,
|
|
372
|
+
inspectMessage: (channel, message) => resources.message(channel, message),
|
|
373
|
+
now: () => new Date(),
|
|
374
|
+
}));
|
|
375
|
+
probes.push(...recordProbes);
|
|
376
|
+
}
|
|
377
|
+
else
|
|
378
|
+
probes.push(fixedIssue("records-unavailable", "Saved records", "incomplete", "Saved-record checks are unavailable."));
|
|
379
|
+
const unique = [
|
|
380
|
+
...new Map(probes.map((probe) => [probe.id, probe])).values(),
|
|
381
|
+
];
|
|
382
|
+
for (const probe of unique) {
|
|
383
|
+
const entry = probe.moduleId ? frozen.get(probe.moduleId) : undefined;
|
|
384
|
+
if (!entry?.customer)
|
|
385
|
+
continue;
|
|
386
|
+
const inspect = probe.inspect;
|
|
387
|
+
probe.inspect = async () => {
|
|
388
|
+
const current = await installations.getModuleState(guildId, entry.module.manifest.id);
|
|
389
|
+
const latestConfig = await configuration.get(guildId, entry.module.manifest.id);
|
|
390
|
+
if (!current?.enabled ||
|
|
391
|
+
current.enabledAt.getTime() !== entry.enabledAt ||
|
|
392
|
+
!sameConfiguration(entry.configuration, latestConfig))
|
|
393
|
+
return {
|
|
394
|
+
kind: "incomplete",
|
|
395
|
+
message: "This module's setup changed during the run. Its newer state was preserved; run /diag again.",
|
|
396
|
+
};
|
|
397
|
+
return inspect();
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (unique.length > MAX_PROBES) {
|
|
401
|
+
unique.splice(MAX_PROBES - 1);
|
|
402
|
+
unique.push(fixedIssue("scan-limit", "Remaining records", "incomplete", "This run reached its record limit. Additional records have not been checked."));
|
|
403
|
+
}
|
|
404
|
+
expected = unique.length;
|
|
405
|
+
// Complete every initial probe before entering either repair round.
|
|
406
|
+
for (const probe of unique) {
|
|
407
|
+
const issue = await stage([
|
|
408
|
+
`Checking ${customerCount} active modules · ${checked}/${expected} checks.`,
|
|
409
|
+
probe.label,
|
|
410
|
+
`${countIssues(observations)} findings so far.`,
|
|
411
|
+
], () => inspectSafely(probe));
|
|
412
|
+
observations.set(probe.id, { probe, issue, fixed: false });
|
|
413
|
+
checked++;
|
|
414
|
+
}
|
|
415
|
+
const findings = [...observations.values()].filter((x) => x.issue);
|
|
416
|
+
for (let i = 0; i < findings.length; i += 2) {
|
|
417
|
+
await stage([
|
|
418
|
+
`${findings.length} findings after all ${checked} checks.`,
|
|
419
|
+
...findings.slice(i, i + 2).map((x) => publicFinding(x)),
|
|
420
|
+
], () => Promise.resolve(undefined));
|
|
421
|
+
}
|
|
422
|
+
for (const round of [1, 2]) {
|
|
423
|
+
const eligible = [...observations.values()].filter((x) => !x.fixed &&
|
|
424
|
+
x.issue?.kind === "repairable" &&
|
|
425
|
+
typeof x.probe.repair === "function");
|
|
426
|
+
if (!eligible.length)
|
|
427
|
+
break;
|
|
428
|
+
for (const observed of eligible) {
|
|
429
|
+
const issue = observed.issue;
|
|
430
|
+
try {
|
|
431
|
+
await stage([
|
|
432
|
+
`Recovery round ${round} · ${eligible.length} supported findings.`,
|
|
433
|
+
observed.probe.label,
|
|
434
|
+
round === 1
|
|
435
|
+
? "Refreshing and confirming the current evidence."
|
|
436
|
+
: "Attempting the supported repair.",
|
|
437
|
+
], async () => {
|
|
438
|
+
if (observed.probe.moduleId)
|
|
439
|
+
await assertCanMutate(observed.probe.moduleId);
|
|
440
|
+
else {
|
|
441
|
+
await assertRunning();
|
|
442
|
+
await assertAdministrator(context.services, resources, context.userId);
|
|
443
|
+
}
|
|
444
|
+
const result = await observed.probe.repair(round, issue);
|
|
445
|
+
if (result === "stale")
|
|
446
|
+
observed.issue = {
|
|
447
|
+
kind: "human",
|
|
448
|
+
message: "Settings or records changed. Review the newer state and run /diag again.",
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (deliveryLost ||
|
|
454
|
+
signal.aborted ||
|
|
455
|
+
error instanceof DiagnosticInterrupted)
|
|
456
|
+
throw error;
|
|
457
|
+
observed.issue = {
|
|
458
|
+
kind: "technical",
|
|
459
|
+
message: "The repair could not safely complete. No further change was attempted.",
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const observed of eligible) {
|
|
464
|
+
if (observed.issue?.kind !== "repairable")
|
|
465
|
+
continue;
|
|
466
|
+
const previous = observed.issue;
|
|
467
|
+
await stage([
|
|
468
|
+
`Verifying recovery round ${round}.`,
|
|
469
|
+
observed.probe.label,
|
|
470
|
+
"Checking the saved result again.",
|
|
471
|
+
], async () => {
|
|
472
|
+
let verified;
|
|
473
|
+
try {
|
|
474
|
+
verified = observed.probe.verify
|
|
475
|
+
? await observed.probe.verify(previous)
|
|
476
|
+
: (await inspectSafely(observed.probe)) === null;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
observed.issue = {
|
|
480
|
+
kind: "incomplete",
|
|
481
|
+
message: "The saved repair result could not be checked. Run /diag again before relying on it.",
|
|
482
|
+
};
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (verified) {
|
|
486
|
+
observed.fixed = true;
|
|
487
|
+
observed.verifiedSnapshot = previous;
|
|
488
|
+
observed.issue = null;
|
|
489
|
+
}
|
|
490
|
+
else
|
|
491
|
+
observed.issue = (await inspectSafely(observed.probe)) ?? {
|
|
492
|
+
kind: "technical",
|
|
493
|
+
message: "The repair result could not be verified.",
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
await stage([
|
|
497
|
+
`Recovery round ${round} checked.`,
|
|
498
|
+
`${observed.probe.label} · ${observed.fixed ? "Fixed" : "Needs attention"}`,
|
|
499
|
+
observed.fixed && previous.afterRepair
|
|
500
|
+
? previous.afterRepair
|
|
501
|
+
: "Only a successful fresh check is reported as Fixed.",
|
|
502
|
+
], () => Promise.resolve(undefined));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Preserve verified fixes, but reread every remaining dependent setup/access check.
|
|
506
|
+
for (const observed of observations.values()) {
|
|
507
|
+
if (observed.fixed || observed.issue?.kind === "technical")
|
|
508
|
+
continue;
|
|
509
|
+
await assertRunning();
|
|
510
|
+
observed.issue = await inspectSafely(observed.probe);
|
|
511
|
+
}
|
|
512
|
+
await assertRunning();
|
|
513
|
+
await show(finalLines(customerCount, checked, expected, observations), currentColor(observations));
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (deliveryLost)
|
|
517
|
+
return;
|
|
518
|
+
const remaining = Math.max(0, expected - checked);
|
|
519
|
+
await show([
|
|
520
|
+
`Checks stopped · ${checked}/${expected || "unknown"} completed.`,
|
|
521
|
+
`${verifiedCount(observations)} verified repairs · ${remaining || "remaining"} checks incomplete.`,
|
|
522
|
+
signal.aborted
|
|
523
|
+
? "The run stopped or timed out. No further repairs will be attempted."
|
|
524
|
+
: "Access, ownership or a required service changed. Run /diag again or use /helyx bug.",
|
|
525
|
+
], RED).catch(() => undefined);
|
|
526
|
+
if (!(error instanceof DiagnosticInterrupted) && !signal.aborted) {
|
|
527
|
+
// The outer runtime receives no raw database or Discord exception.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
void started;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function sameConfiguration(a, b) {
|
|
535
|
+
return (a?.version === b?.version &&
|
|
536
|
+
a?.schemaVersion === b?.schemaVersion &&
|
|
537
|
+
isDeepStrictEqual(a?.value, b?.value));
|
|
538
|
+
}
|
|
539
|
+
async function inspectSafely(probe) {
|
|
540
|
+
try {
|
|
541
|
+
return await probe.inspect();
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return {
|
|
545
|
+
kind: "incomplete",
|
|
546
|
+
message: "This check could not finish. Retry later or ask support.",
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function fixedIssue(id, label, kind, message) {
|
|
551
|
+
return { id, label, inspect: () => Promise.resolve({ kind, message }) };
|
|
552
|
+
}
|
|
553
|
+
function report(ephemeral, accentColor, lines) {
|
|
554
|
+
return {
|
|
555
|
+
ephemeral,
|
|
556
|
+
componentsV2: {
|
|
557
|
+
accentColor,
|
|
558
|
+
text: [
|
|
559
|
+
{ content: "## Helyx Diagnostic Report" },
|
|
560
|
+
{ content: lines.slice(0, 3).map(safeLine).join("\n") },
|
|
561
|
+
],
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function safeLine(value) {
|
|
566
|
+
return value
|
|
567
|
+
.replace(/[\r\n]+/g, " ")
|
|
568
|
+
.replace(/[@<>]/g, "")
|
|
569
|
+
.replace(/([\\`*_{}[\]()])/g, "\\$1")
|
|
570
|
+
.slice(0, 450);
|
|
571
|
+
}
|
|
572
|
+
function countIssues(items) {
|
|
573
|
+
return [...items.values()].filter((x) => x.issue).length;
|
|
574
|
+
}
|
|
575
|
+
function verifiedCount(items) {
|
|
576
|
+
return [...items.values()].filter((x) => x.fixed).length;
|
|
577
|
+
}
|
|
578
|
+
function publicFinding(item) {
|
|
579
|
+
return `${item.probe.label} · ${item.issue?.kind === "repairable" ? "Supported repair available" : (item.issue?.message ?? "Checked")}`;
|
|
580
|
+
}
|
|
581
|
+
function currentColor(items) {
|
|
582
|
+
const values = [...items.values()];
|
|
583
|
+
if (values.some((x) => x.issue?.kind === "technical" || x.issue?.kind === "repairable"))
|
|
584
|
+
return RED;
|
|
585
|
+
if (values.some((x) => x.issue || x.verifiedSnapshot?.afterRepair))
|
|
586
|
+
return ORANGE;
|
|
587
|
+
return GREEN;
|
|
588
|
+
}
|
|
589
|
+
function finalLines(modules, checked, total, items) {
|
|
590
|
+
const values = [...items.values()];
|
|
591
|
+
const incomplete = values.filter((x) => x.issue?.kind === "incomplete").length;
|
|
592
|
+
const followups = values.filter((x) => x.fixed && x.verifiedSnapshot?.afterRepair);
|
|
593
|
+
const unresolved = values.filter((x) => x.issue && x.issue.kind !== "incomplete");
|
|
594
|
+
const needs = unresolved.length + followups.length;
|
|
595
|
+
return [
|
|
596
|
+
`${modules} active modules · ${checked}/${total} checks completed.`,
|
|
597
|
+
`${verifiedCount(items)} verified repairs · ${needs} need attention · ${incomplete} incomplete checks.`,
|
|
598
|
+
needs || incomplete
|
|
599
|
+
? `${followups[0]?.verifiedSnapshot?.afterRepair ?? unresolved[0]?.issue?.message ?? "Some checks could not finish."} Use /helyx bug if unresolved.`
|
|
600
|
+
: "No outstanding problem was detected in the checked scope.",
|
|
601
|
+
];
|
|
602
|
+
}
|
|
603
|
+
function coreProbes(options, context, resources, assertRunning) {
|
|
604
|
+
if (!options.commandStatus || !resources.guildCommands)
|
|
605
|
+
return [
|
|
606
|
+
fixedIssue("commands.coverage", "Discord command status", "incomplete", "Discord command status cannot be checked on this bot."),
|
|
607
|
+
];
|
|
608
|
+
const status = options.commandStatus;
|
|
609
|
+
const guildId = context.serverId;
|
|
610
|
+
const read = async () => {
|
|
611
|
+
const raw = await status.get(guildId);
|
|
612
|
+
if (!raw ||
|
|
613
|
+
typeof raw !== "object" ||
|
|
614
|
+
!("state" in raw) ||
|
|
615
|
+
!raw.state ||
|
|
616
|
+
typeof raw.state !== "object")
|
|
617
|
+
throw new Error("Missing command state");
|
|
618
|
+
const state = raw.state;
|
|
619
|
+
if (typeof state.desiredRevision !== "string" ||
|
|
620
|
+
typeof state.visibilityProfile !== "string")
|
|
621
|
+
throw new Error("Invalid command state");
|
|
622
|
+
const observed = await resources.guildCommands();
|
|
623
|
+
return {
|
|
624
|
+
state,
|
|
625
|
+
observed,
|
|
626
|
+
busy: "activeRequest" in raw && raw.activeRequest != null,
|
|
627
|
+
};
|
|
628
|
+
};
|
|
629
|
+
return [
|
|
630
|
+
{
|
|
631
|
+
id: "commands.stale-observation",
|
|
632
|
+
label: "Discord command status",
|
|
633
|
+
async inspect() {
|
|
634
|
+
const { state, observed, busy } = await read();
|
|
635
|
+
if (busy || state.state === "pending" || state.state === "running")
|
|
636
|
+
return {
|
|
637
|
+
kind: "human",
|
|
638
|
+
message: "A command update is already pending. Check Commands in the dashboard after it finishes.",
|
|
639
|
+
};
|
|
640
|
+
if (state.desiredDefinitionHash !== observed.definitionHash ||
|
|
641
|
+
state.desiredRootCount !== observed.rootCount)
|
|
642
|
+
return {
|
|
643
|
+
kind: "human",
|
|
644
|
+
message: "Discord commands do not match this server's command setup. Review Commands in the dashboard.",
|
|
645
|
+
};
|
|
646
|
+
if (state.lastObservedDefinitionHash !== observed.definitionHash ||
|
|
647
|
+
state.installedRootCount !== observed.rootCount)
|
|
648
|
+
return {
|
|
649
|
+
kind: "repairable",
|
|
650
|
+
message: "The saved command status is out of date; the commands themselves already match.",
|
|
651
|
+
evidence: {
|
|
652
|
+
desiredRevision: state.desiredRevision,
|
|
653
|
+
visibilityProfile: state.visibilityProfile,
|
|
654
|
+
definitionHash: observed.definitionHash,
|
|
655
|
+
rootCount: observed.rootCount,
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
return null;
|
|
659
|
+
},
|
|
660
|
+
async repair(round, issue) {
|
|
661
|
+
// Both rounds use the same revision-guarded, metadata-only refresh.
|
|
662
|
+
// No command reconciliation, profile change or Discord write is requested.
|
|
663
|
+
void round;
|
|
664
|
+
const current = await read();
|
|
665
|
+
if (current.busy ||
|
|
666
|
+
current.state.desiredRevision !== issue.evidence?.desiredRevision ||
|
|
667
|
+
current.state.visibilityProfile !==
|
|
668
|
+
issue.evidence?.visibilityProfile ||
|
|
669
|
+
current.state.desiredDefinitionHash !==
|
|
670
|
+
issue.evidence?.definitionHash ||
|
|
671
|
+
current.observed.definitionHash !== issue.evidence?.definitionHash ||
|
|
672
|
+
current.observed.rootCount !== issue.evidence?.rootCount)
|
|
673
|
+
return "stale";
|
|
674
|
+
await assertRunning();
|
|
675
|
+
await assertAdministrator(context.services, resources, context.userId);
|
|
676
|
+
await status.refresh({
|
|
677
|
+
guildId,
|
|
678
|
+
actorUserId: context.userId,
|
|
679
|
+
correlationId: context.interactionId,
|
|
680
|
+
});
|
|
681
|
+
return "attempted";
|
|
682
|
+
},
|
|
683
|
+
async verify(issue) {
|
|
684
|
+
const { state, observed, busy } = await read();
|
|
685
|
+
return (!busy &&
|
|
686
|
+
state.desiredRevision === issue.evidence?.desiredRevision &&
|
|
687
|
+
state.visibilityProfile === issue.evidence?.visibilityProfile &&
|
|
688
|
+
state.desiredDefinitionHash === observed.definitionHash &&
|
|
689
|
+
observed.definitionHash === issue.evidence?.definitionHash &&
|
|
690
|
+
state.lastObservedDefinitionHash === observed.definitionHash &&
|
|
691
|
+
state.desiredRootCount === observed.rootCount &&
|
|
692
|
+
state.installedRootCount === observed.rootCount);
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
];
|
|
696
|
+
}
|
|
697
|
+
function permissionProbes(entries, services, guildId) {
|
|
698
|
+
// Closed outcome catalogue. Public member submissions/votes and an "everyone"
|
|
699
|
+
// flag on unrelated policies are never treated as dangerous exposure.
|
|
700
|
+
const moderation = entries.find((x) => x.customer && x.module.manifest.id === "helyx.moderation");
|
|
701
|
+
if (!moderation)
|
|
702
|
+
return [];
|
|
703
|
+
const routeExists = moderation.module.interactions?.commands?.some((root) => root.subcommands?.some((command) => command.permissionId === "moderation.unban" &&
|
|
704
|
+
command.authorization === "resource-scoped"));
|
|
705
|
+
if (!routeExists)
|
|
706
|
+
return [
|
|
707
|
+
fixedIssue("permissions.moderation-coverage", "Moderation command access", "incomplete", "This installed Moderation version needs a separate command-access review."),
|
|
708
|
+
];
|
|
709
|
+
return [
|
|
710
|
+
{
|
|
711
|
+
id: "permissions.moderation-unban",
|
|
712
|
+
moduleId: moderation.module.manifest.id,
|
|
713
|
+
label: "Moderation unban access",
|
|
714
|
+
async inspect() {
|
|
715
|
+
const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
716
|
+
const policy = await permissions.getEffectivePolicy(guildId, moderation.module.manifest.id, "moderation.unban");
|
|
717
|
+
const access = await permissions.getGuildAccessPolicy?.(guildId);
|
|
718
|
+
// An unban has no target hierarchy gate. Its resource authorization service
|
|
719
|
+
// evaluates this exact effective policy; checking it never executes an action.
|
|
720
|
+
const ordinaryMember = {
|
|
721
|
+
userId: "diagnostic-non-member",
|
|
722
|
+
roleIds: [guildId],
|
|
723
|
+
permissions: [],
|
|
724
|
+
isServerOwner: false,
|
|
725
|
+
isAdministrator: false,
|
|
726
|
+
};
|
|
727
|
+
if (canUseCommand(ordinaryMember, policy, access))
|
|
728
|
+
return {
|
|
729
|
+
kind: "human",
|
|
730
|
+
message: "The unban permission allows ordinary members. Restrict it in Moderation's Permissions settings.",
|
|
731
|
+
};
|
|
732
|
+
return null;
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
];
|
|
736
|
+
}
|
|
737
|
+
const DIAGNOSTIC_LOCK_QUERY_TIMEOUT_MS = 10_000;
|
|
738
|
+
const diagnosticLockCapacity = new WeakMap();
|
|
739
|
+
function diagnosticPostgresLockQuery(text, key) {
|
|
740
|
+
// pg supports query_timeout at runtime although QueryConfig omits it. A
|
|
741
|
+
// named structural config preserves the deadline without an unsafe type cast.
|
|
742
|
+
return {
|
|
743
|
+
text,
|
|
744
|
+
values: [key],
|
|
745
|
+
query_timeout: DIAGNOSTIC_LOCK_QUERY_TIMEOUT_MS,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function reserveDiagnosticLockCapacity(pool, maxConnections) {
|
|
749
|
+
const budget = Number.isSafeInteger(maxConnections)
|
|
750
|
+
? Math.min(4, maxConnections - 2)
|
|
751
|
+
: 0;
|
|
752
|
+
const current = diagnosticLockCapacity.get(pool) ?? 0;
|
|
753
|
+
if (budget < 1 || current >= budget) {
|
|
754
|
+
throw new Error("Diagnostic database lock capacity is unavailable; retry later");
|
|
755
|
+
}
|
|
756
|
+
// Synchronous reservation before awaiting a connection: no local wait queue.
|
|
757
|
+
diagnosticLockCapacity.set(pool, current + 1);
|
|
758
|
+
return () => {
|
|
759
|
+
const remaining = (diagnosticLockCapacity.get(pool) ?? 1) - 1;
|
|
760
|
+
if (remaining === 0)
|
|
761
|
+
diagnosticLockCapacity.delete(pool);
|
|
762
|
+
else
|
|
763
|
+
diagnosticLockCapacity.set(pool, remaining);
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function diagnosticLockKey(guildId) {
|
|
767
|
+
if (!/^[0-9]{1,22}$/u.test(guildId)) {
|
|
768
|
+
throw new Error("Diagnostic guild ID must be a Discord snowflake");
|
|
769
|
+
}
|
|
770
|
+
return `helyx:diag:${guildId}`;
|
|
771
|
+
}
|
|
772
|
+
/** Reserve at most four sessions, leaving at least two pool slots for repairs. */
|
|
773
|
+
export function createPostgresDiagnosticLock(pool) {
|
|
774
|
+
return async (guildId, operation) => {
|
|
775
|
+
const key = diagnosticLockKey(guildId);
|
|
776
|
+
const releaseCapacity = reserveDiagnosticLockCapacity(pool, pool.options.max);
|
|
777
|
+
try {
|
|
778
|
+
const connection = await pool.connect();
|
|
779
|
+
const controller = new AbortController();
|
|
780
|
+
let acquired = false;
|
|
781
|
+
let reusable = false;
|
|
782
|
+
const loseLease = (cause) => {
|
|
783
|
+
reusable = false;
|
|
784
|
+
controller.abort(new Error("Diagnostic database lock ownership was lost", { cause }));
|
|
785
|
+
};
|
|
786
|
+
const endLease = () => loseLease();
|
|
787
|
+
connection.on("error", loseLease);
|
|
788
|
+
connection.on("end", endLease);
|
|
789
|
+
const lease = {
|
|
790
|
+
signal: controller.signal,
|
|
791
|
+
async assertOwned() {
|
|
792
|
+
controller.signal.throwIfAborted();
|
|
793
|
+
try {
|
|
794
|
+
// Advisory locks are reentrant. Checking pg_locks avoids acquiring a
|
|
795
|
+
// second lock (and avoids hiding a lost first lock by reacquiring it).
|
|
796
|
+
const result = await connection.query(diagnosticPostgresLockQuery(`SELECT EXISTS (
|
|
797
|
+
SELECT 1 FROM pg_locks
|
|
798
|
+
WHERE locktype = 'advisory' AND pid = pg_backend_pid()
|
|
799
|
+
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
800
|
+
AND granted AND objsubid = 1
|
|
801
|
+
AND classid::bigint = ((hashtextextended($1, 0) >> 32) & 4294967295)
|
|
802
|
+
AND objid::bigint = (hashtextextended($1, 0) & 4294967295)
|
|
803
|
+
) AS owned`, key));
|
|
804
|
+
if (result.rows[0]?.owned !== true)
|
|
805
|
+
loseLease();
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
loseLease(error);
|
|
809
|
+
}
|
|
810
|
+
controller.signal.throwIfAborted();
|
|
811
|
+
},
|
|
812
|
+
};
|
|
813
|
+
try {
|
|
814
|
+
const result = await connection.query(diagnosticPostgresLockQuery("SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired", key));
|
|
815
|
+
controller.signal.throwIfAborted();
|
|
816
|
+
if (result.rows[0]?.acquired === false) {
|
|
817
|
+
reusable = true;
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
if (result.rows[0]?.acquired !== true) {
|
|
821
|
+
throw new Error("Diagnostic database lock acquisition could not be confirmed");
|
|
822
|
+
}
|
|
823
|
+
acquired = true;
|
|
824
|
+
await lease.assertOwned();
|
|
825
|
+
// No timeout race: keep the reserved session until all callback work has
|
|
826
|
+
// drained, including cancellation handling and any in-flight repair.
|
|
827
|
+
await operation(lease);
|
|
828
|
+
await lease.assertOwned();
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
if (acquired && !controller.signal.aborted) {
|
|
833
|
+
try {
|
|
834
|
+
const result = await connection.query(diagnosticPostgresLockQuery("SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS released", key));
|
|
835
|
+
reusable =
|
|
836
|
+
result.rows[0]?.released === true && !controller.signal.aborted;
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
loseLease(error);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
controller.abort(new Error("Diagnostic database lock lease has ended"));
|
|
843
|
+
connection.off("error", loseLease);
|
|
844
|
+
connection.off("end", endLease);
|
|
845
|
+
connection.release(!reusable);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
releaseCapacity();
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function diagnosticMysqlScalar(rows, field) {
|
|
854
|
+
if (!Array.isArray(rows))
|
|
855
|
+
return undefined;
|
|
856
|
+
const row = rows[0];
|
|
857
|
+
if (typeof row !== "object" || row === null || !(field in row))
|
|
858
|
+
return undefined;
|
|
859
|
+
return row[field];
|
|
860
|
+
}
|
|
861
|
+
/** Reserve at most four sessions, leaving at least two pool slots for repairs. */
|
|
862
|
+
export function createMysqlDiagnosticLock(pool) {
|
|
863
|
+
return async (guildId, operation) => {
|
|
864
|
+
const key = diagnosticLockKey(guildId);
|
|
865
|
+
const releaseCapacity = reserveDiagnosticLockCapacity(pool.pool, pool.pool.config.connectionLimit ?? 0);
|
|
866
|
+
try {
|
|
867
|
+
const connection = await pool.getConnection();
|
|
868
|
+
const controller = new AbortController();
|
|
869
|
+
let acquired = false;
|
|
870
|
+
let reusable = false;
|
|
871
|
+
const loseLease = (cause) => {
|
|
872
|
+
reusable = false;
|
|
873
|
+
controller.abort(new Error("Diagnostic database lock ownership was lost", { cause }));
|
|
874
|
+
};
|
|
875
|
+
const endLease = () => loseLease();
|
|
876
|
+
connection.on("error", loseLease);
|
|
877
|
+
connection.on("end", endLease);
|
|
878
|
+
const queryScalar = async (sql, field) => {
|
|
879
|
+
const [rows] = await connection.query({
|
|
880
|
+
sql,
|
|
881
|
+
timeout: DIAGNOSTIC_LOCK_QUERY_TIMEOUT_MS,
|
|
882
|
+
}, [key]);
|
|
883
|
+
return diagnosticMysqlScalar(rows, field);
|
|
884
|
+
};
|
|
885
|
+
const lease = {
|
|
886
|
+
signal: controller.signal,
|
|
887
|
+
async assertOwned() {
|
|
888
|
+
controller.signal.throwIfAborted();
|
|
889
|
+
try {
|
|
890
|
+
// IS_USED_LOCK reads ownership without taking a reentrant GET_LOCK.
|
|
891
|
+
const owned = await queryScalar("SELECT (IS_USED_LOCK(?) = CONNECTION_ID()) AS owned", "owned");
|
|
892
|
+
if (owned !== 1 && owned !== "1")
|
|
893
|
+
loseLease();
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
loseLease(error);
|
|
897
|
+
}
|
|
898
|
+
controller.signal.throwIfAborted();
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
try {
|
|
902
|
+
const result = await queryScalar("SELECT GET_LOCK(?, 0) AS acquired", "acquired");
|
|
903
|
+
controller.signal.throwIfAborted();
|
|
904
|
+
if (result === 0 || result === "0") {
|
|
905
|
+
reusable = true;
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
if (result !== 1 && result !== "1") {
|
|
909
|
+
throw new Error("Diagnostic database lock acquisition could not be confirmed");
|
|
910
|
+
}
|
|
911
|
+
acquired = true;
|
|
912
|
+
await lease.assertOwned();
|
|
913
|
+
await operation(lease);
|
|
914
|
+
await lease.assertOwned();
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
if (acquired && !controller.signal.aborted) {
|
|
919
|
+
try {
|
|
920
|
+
const result = await queryScalar("SELECT RELEASE_LOCK(?) AS released", "released");
|
|
921
|
+
reusable =
|
|
922
|
+
(result === 1 || result === "1") && !controller.signal.aborted;
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
loseLease(error);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
controller.abort(new Error("Diagnostic database lock lease has ended"));
|
|
929
|
+
connection.off("error", loseLease);
|
|
930
|
+
connection.off("end", endLease);
|
|
931
|
+
if (reusable)
|
|
932
|
+
connection.release();
|
|
933
|
+
else
|
|
934
|
+
connection.destroy();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
finally {
|
|
938
|
+
releaseCapacity();
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
async function createRecordProbes(env) {
|
|
943
|
+
const probes = [];
|
|
944
|
+
const specs = [
|
|
945
|
+
{
|
|
946
|
+
moduleId: "helyx.sticky-messages",
|
|
947
|
+
collection: "stickies",
|
|
948
|
+
key: "sticky_id",
|
|
949
|
+
label: "Sticky Messages",
|
|
950
|
+
mode: "sticky",
|
|
951
|
+
pointer: "current_message_id",
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
moduleId: "helyx.message-embedder",
|
|
955
|
+
collection: "messages",
|
|
956
|
+
key: "definition_id",
|
|
957
|
+
label: "Message Embedder",
|
|
958
|
+
mode: "embedder",
|
|
959
|
+
pointer: "message_id",
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
moduleId: "helyx.message-embedder",
|
|
963
|
+
collection: "ownedMessageCleanup",
|
|
964
|
+
key: "cleanup_id",
|
|
965
|
+
label: "Message Embedder cleanup",
|
|
966
|
+
mode: "orphan",
|
|
967
|
+
pointer: "message_id",
|
|
968
|
+
},
|
|
969
|
+
];
|
|
970
|
+
for (const spec of specs) {
|
|
971
|
+
await env.assertCanInspect();
|
|
972
|
+
const module = env.modules.find((item) => item.manifest.id === spec.moduleId);
|
|
973
|
+
if (!module)
|
|
974
|
+
continue;
|
|
975
|
+
const collection = module.records?.[spec.collection];
|
|
976
|
+
const unavailable = (message) => probes.push({
|
|
977
|
+
id: `${spec.moduleId}.${spec.collection}.coverage`,
|
|
978
|
+
moduleId: spec.moduleId,
|
|
979
|
+
label: `${spec.label} saved records`,
|
|
980
|
+
inspect: () => Promise.resolve({ kind: "incomplete", message }),
|
|
981
|
+
});
|
|
982
|
+
if (!collection || !diagnosticRecordSchemaSupported(spec, collection)) {
|
|
983
|
+
unavailable(`${spec.label} record checks are unavailable for this installed version. No saved records were changed.`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (spec.mode !== "orphan") {
|
|
987
|
+
try {
|
|
988
|
+
env.services.get(HELYX_SERVICE_NAMES.messageBuilder);
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
unavailable(`${spec.label} message validation is unavailable. Its saved records were not repaired.`);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// Exact server filter and declared unique ascending order. Never use the
|
|
996
|
+
// deployment-wide recovery API. Overflow invalidates the whole collection.
|
|
997
|
+
const rows = [];
|
|
998
|
+
let cursor;
|
|
999
|
+
let complete = false;
|
|
1000
|
+
try {
|
|
1001
|
+
while (rows.length < 200) {
|
|
1002
|
+
await env.assertCanInspect();
|
|
1003
|
+
const page = await env.records.findMany({
|
|
1004
|
+
moduleId: spec.moduleId,
|
|
1005
|
+
collection: spec.collection,
|
|
1006
|
+
select: collection.readableFields,
|
|
1007
|
+
where: { guild_id: env.guildId },
|
|
1008
|
+
orderBy: [{ field: spec.key, direction: "asc" }],
|
|
1009
|
+
...(cursor ? { cursor } : {}),
|
|
1010
|
+
limit: Math.min(50, 200 - rows.length),
|
|
1011
|
+
});
|
|
1012
|
+
if (page.records.length > Math.min(50, 200 - rows.length))
|
|
1013
|
+
throw new Error("Invalid bounded page");
|
|
1014
|
+
for (const row of page.records) {
|
|
1015
|
+
if (row.guild_id !== env.guildId ||
|
|
1016
|
+
typeof row[spec.key] !== "string" ||
|
|
1017
|
+
!row[spec.key])
|
|
1018
|
+
throw new Error("Invalid scoped record");
|
|
1019
|
+
if (rows.some((existing) => existing[spec.key] === row[spec.key]))
|
|
1020
|
+
throw new Error("Repeated keyset page");
|
|
1021
|
+
rows.push(structuredClone(row));
|
|
1022
|
+
}
|
|
1023
|
+
if (!page.nextCursor) {
|
|
1024
|
+
complete = true;
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
if (!page.records.length ||
|
|
1028
|
+
diagnosticRecordSame(page.nextCursor, cursor))
|
|
1029
|
+
throw new Error("Nonadvancing keyset page");
|
|
1030
|
+
cursor = page.nextCursor;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
if (error instanceof DiagnosticInterrupted ||
|
|
1035
|
+
(error instanceof Error && error.name === "AbortError"))
|
|
1036
|
+
throw error;
|
|
1037
|
+
unavailable(`${spec.label} saved records could not be read completely. No records in this collection were repaired.`);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (!complete) {
|
|
1041
|
+
unavailable(`${spec.label} has more records than this run can safely check (200). No records in this collection were repaired.`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (!rows.length) {
|
|
1045
|
+
probes.push({
|
|
1046
|
+
id: `${spec.moduleId}.${spec.collection}.empty`,
|
|
1047
|
+
moduleId: spec.moduleId,
|
|
1048
|
+
label: `${spec.label} saved records`,
|
|
1049
|
+
inspect: () => Promise.resolve(null),
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
for (const row of rows) {
|
|
1053
|
+
const key = String(row[spec.key]);
|
|
1054
|
+
if (!diagnosticRecordValid(spec, collection, row, env)) {
|
|
1055
|
+
probes.push({
|
|
1056
|
+
id: `${spec.moduleId}.${spec.collection}.${key}.invalid`,
|
|
1057
|
+
moduleId: spec.moduleId,
|
|
1058
|
+
label: `${spec.label} saved record`,
|
|
1059
|
+
inspect: () => Promise.resolve({
|
|
1060
|
+
kind: "human",
|
|
1061
|
+
message: `${spec.label} has an invalid saved record. Review it in the dashboard; its content has been preserved.`,
|
|
1062
|
+
}),
|
|
1063
|
+
});
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (spec.mode === "orphan") {
|
|
1067
|
+
probes.push(diagnosticRecordProbe(env, spec, collection, row, "orphan"));
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
// One lifecycle probe owns this row. Two probes with the same frozen
|
|
1071
|
+
// revision would otherwise misreport our own verified cleanup as a
|
|
1072
|
+
// concurrent edit during the final dependent recheck.
|
|
1073
|
+
probes.push(diagnosticRecordProbe(env, spec, collection, row, row.cleanup_message_id || row.cleanup_channel_id
|
|
1074
|
+
? "cleanup"
|
|
1075
|
+
: "delivery"));
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return probes;
|
|
1080
|
+
}
|
|
1081
|
+
function diagnosticRecordProbe(env, spec, collection, initial, purpose) {
|
|
1082
|
+
const key = String(initial[spec.key]);
|
|
1083
|
+
const fixId = purpose === "orphan"
|
|
1084
|
+
? "message-embedder.orphan-cleanup-status"
|
|
1085
|
+
: `${spec.mode === "sticky" ? "sticky" : "message-embedder"}.${purpose === "delivery" ? "dead-delivery-pointer" : "completed-cleanup-pointer"}`;
|
|
1086
|
+
let expected;
|
|
1087
|
+
let wrote = false;
|
|
1088
|
+
const find = () => env.records.findOne({
|
|
1089
|
+
moduleId: spec.moduleId,
|
|
1090
|
+
collection: spec.collection,
|
|
1091
|
+
select: collection.readableFields,
|
|
1092
|
+
// Cleanup collection has an exact declared compound key; keep guild scope
|
|
1093
|
+
// even though cleanup_id is globally unique.
|
|
1094
|
+
where: purpose === "orphan"
|
|
1095
|
+
? {
|
|
1096
|
+
guild_id: env.guildId,
|
|
1097
|
+
channel_id: String(initial.channel_id),
|
|
1098
|
+
message_id: String(initial.message_id),
|
|
1099
|
+
}
|
|
1100
|
+
: { guild_id: env.guildId, [spec.key]: key },
|
|
1101
|
+
});
|
|
1102
|
+
const applicable = (row) => {
|
|
1103
|
+
if (purpose === "orphan")
|
|
1104
|
+
return row.status === "pending";
|
|
1105
|
+
if (purpose === "delivery")
|
|
1106
|
+
return ((row.status === "active" ||
|
|
1107
|
+
row.status ===
|
|
1108
|
+
(spec.mode === "sticky" ? "missing" : "message_missing")) &&
|
|
1109
|
+
!row.cleanup_message_id &&
|
|
1110
|
+
!row.cleanup_channel_id);
|
|
1111
|
+
return ((spec.mode === "sticky"
|
|
1112
|
+
? row.status === "cleanup"
|
|
1113
|
+
: row.status === "active" || row.status === "needs_attention") &&
|
|
1114
|
+
!!row.cleanup_channel_id &&
|
|
1115
|
+
!!row.cleanup_message_id);
|
|
1116
|
+
};
|
|
1117
|
+
const evidence = async (row) => {
|
|
1118
|
+
if (purpose !== "cleanup") {
|
|
1119
|
+
if (!row[spec.pointer])
|
|
1120
|
+
return "present";
|
|
1121
|
+
return env.inspectMessage(String(row.channel_id), String(row[spec.pointer]));
|
|
1122
|
+
}
|
|
1123
|
+
// Clearing a cleanup marker cannot make a missing replacement post healthy.
|
|
1124
|
+
// Require fresh existence evidence for that replacement before status active.
|
|
1125
|
+
if (!row[spec.pointer])
|
|
1126
|
+
return "unknown";
|
|
1127
|
+
if ((await env.inspectMessage(String(row.channel_id), String(row[spec.pointer]))) !== "present")
|
|
1128
|
+
return "unknown";
|
|
1129
|
+
return env.inspectMessage(String(row.cleanup_channel_id), String(row.cleanup_message_id));
|
|
1130
|
+
};
|
|
1131
|
+
const probe = {
|
|
1132
|
+
id: `${fixId}:${key}`,
|
|
1133
|
+
moduleId: spec.moduleId,
|
|
1134
|
+
label: `${spec.label} ${purpose === "delivery" ? "message link" : "completed cleanup"}`,
|
|
1135
|
+
async inspect() {
|
|
1136
|
+
const row = await find();
|
|
1137
|
+
if (!row)
|
|
1138
|
+
return {
|
|
1139
|
+
kind: "incomplete",
|
|
1140
|
+
message: `${spec.label} changed during the check. Run /diag again.`,
|
|
1141
|
+
};
|
|
1142
|
+
if (!diagnosticRecordSame(row, initial))
|
|
1143
|
+
return {
|
|
1144
|
+
kind: "incomplete",
|
|
1145
|
+
message: `${spec.label} changed during the check. Its newer settings were preserved.`,
|
|
1146
|
+
};
|
|
1147
|
+
// The paired cleanup probe owns this lifecycle and rechecks both messages.
|
|
1148
|
+
// Do not leave an unrelated human warning after verified cleanup succeeds.
|
|
1149
|
+
if (purpose === "delivery" &&
|
|
1150
|
+
row.cleanup_channel_id &&
|
|
1151
|
+
row.cleanup_message_id &&
|
|
1152
|
+
(spec.mode === "sticky"
|
|
1153
|
+
? row.status === "cleanup"
|
|
1154
|
+
: row.status === "active" || row.status === "needs_attention"))
|
|
1155
|
+
return null;
|
|
1156
|
+
if (!applicable(row)) {
|
|
1157
|
+
if (row.status === "paused")
|
|
1158
|
+
return null;
|
|
1159
|
+
return {
|
|
1160
|
+
kind: "human",
|
|
1161
|
+
message: `${spec.label} needs review or has work in progress. Use its dashboard controls; diagnostics will not reset that state.`,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
if (purpose === "delivery" && !row[spec.pointer]) {
|
|
1165
|
+
return {
|
|
1166
|
+
kind: "human",
|
|
1167
|
+
message: `${spec.label} has no published message. Use its dashboard repair or publish control.`,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
const state = await evidence(row);
|
|
1171
|
+
if (state === "unknown")
|
|
1172
|
+
return {
|
|
1173
|
+
kind: "incomplete",
|
|
1174
|
+
message: `${spec.label} message access could not be confirmed. Check channel access and try again; nothing was removed.`,
|
|
1175
|
+
};
|
|
1176
|
+
if (state === "present") {
|
|
1177
|
+
if (purpose === "orphan")
|
|
1178
|
+
return {
|
|
1179
|
+
kind: "human",
|
|
1180
|
+
message: `${spec.label} still has pending message cleanup. Its normal recovery process must complete it.`,
|
|
1181
|
+
};
|
|
1182
|
+
if (purpose === "delivery" && row.status !== "active")
|
|
1183
|
+
return {
|
|
1184
|
+
kind: "human",
|
|
1185
|
+
message: `${spec.label} has a message but its saved status needs review. Use its dashboard controls.`,
|
|
1186
|
+
};
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
if (purpose === "orphan")
|
|
1190
|
+
return {
|
|
1191
|
+
kind: "human",
|
|
1192
|
+
message: `${spec.label} retains a cleanup record for a deleted message. Its normal recovery process must finish removing that record; diagnostics leaves it unchanged.`,
|
|
1193
|
+
};
|
|
1194
|
+
return {
|
|
1195
|
+
kind: "repairable",
|
|
1196
|
+
message: purpose === "delivery"
|
|
1197
|
+
? `${spec.label} points to a message Discord confirms was deleted.`
|
|
1198
|
+
: `${spec.label} retains cleanup state for a message Discord confirms was deleted.`,
|
|
1199
|
+
evidence: { fixId, initial: structuredClone(row) },
|
|
1200
|
+
...(purpose === "delivery"
|
|
1201
|
+
? {
|
|
1202
|
+
afterRepair: `${spec.label} message link was corrected. Use its dashboard repair or publish control to restore the message.`,
|
|
1203
|
+
}
|
|
1204
|
+
: {}),
|
|
1205
|
+
};
|
|
1206
|
+
},
|
|
1207
|
+
async repair(round, issue) {
|
|
1208
|
+
// Read-only orphan probes never expose this method. Keep a defensive
|
|
1209
|
+
// guard here so later refactoring cannot accidentally enable deletion.
|
|
1210
|
+
if (purpose === "orphan")
|
|
1211
|
+
return "unchanged";
|
|
1212
|
+
if (round === 1)
|
|
1213
|
+
return "unchanged";
|
|
1214
|
+
if (issue.evidence?.fixId !== fixId ||
|
|
1215
|
+
!diagnosticRecordSame(issue.evidence.initial, initial))
|
|
1216
|
+
return "stale";
|
|
1217
|
+
const row = await find();
|
|
1218
|
+
if (!row ||
|
|
1219
|
+
!diagnosticRecordSame(row, initial) ||
|
|
1220
|
+
!diagnosticRecordValid(spec, collection, row, env) ||
|
|
1221
|
+
!applicable(row))
|
|
1222
|
+
return "stale";
|
|
1223
|
+
if ((await evidence(row)) !== "absent")
|
|
1224
|
+
return "stale";
|
|
1225
|
+
// Check authority, distributed lease, enabled/admitted module and frozen
|
|
1226
|
+
// module/configuration versions immediately before the guarded write.
|
|
1227
|
+
await env.assertCanMutate(spec.moduleId);
|
|
1228
|
+
const values = {
|
|
1229
|
+
revision: Number(row.revision) + 1,
|
|
1230
|
+
updated_at: env.now(),
|
|
1231
|
+
...(spec.mode === "sticky"
|
|
1232
|
+
? { generation: Number(row.generation) + 1 }
|
|
1233
|
+
: {}),
|
|
1234
|
+
...(purpose === "delivery"
|
|
1235
|
+
? {
|
|
1236
|
+
[spec.pointer]: null,
|
|
1237
|
+
status: spec.mode === "sticky" ? "missing" : "message_missing",
|
|
1238
|
+
}
|
|
1239
|
+
: {
|
|
1240
|
+
cleanup_channel_id: null,
|
|
1241
|
+
cleanup_message_id: null,
|
|
1242
|
+
status: "active",
|
|
1243
|
+
}),
|
|
1244
|
+
};
|
|
1245
|
+
expected = { ...row, ...values };
|
|
1246
|
+
const result = await env.records.updateOne({
|
|
1247
|
+
moduleId: spec.moduleId,
|
|
1248
|
+
collection: spec.collection,
|
|
1249
|
+
where: { guild_id: env.guildId, [spec.key]: key },
|
|
1250
|
+
expected: { field: "revision", value: row.revision },
|
|
1251
|
+
requireModuleEnabledForGuildId: env.guildId,
|
|
1252
|
+
values,
|
|
1253
|
+
audit: diagnosticRecordAudit(env, spec, fixId, key, row),
|
|
1254
|
+
});
|
|
1255
|
+
wrote = result.updated;
|
|
1256
|
+
return wrote ? "attempted" : "stale";
|
|
1257
|
+
},
|
|
1258
|
+
async verify() {
|
|
1259
|
+
if (!wrote)
|
|
1260
|
+
return false;
|
|
1261
|
+
const fresh = await find();
|
|
1262
|
+
if (!fresh || !expected || !diagnosticRecordSame(fresh, expected))
|
|
1263
|
+
return false;
|
|
1264
|
+
// Exact full-row comparison includes content, destination, authorship and
|
|
1265
|
+
// every other readable protected field, not merely a successful SQL write.
|
|
1266
|
+
return (await evidence(initial)) === "absent";
|
|
1267
|
+
},
|
|
1268
|
+
};
|
|
1269
|
+
// SDK deleteOne cannot lock/recheck module enablement in its transaction.
|
|
1270
|
+
// The guild record fence is unrelated to installation-state locks, so it
|
|
1271
|
+
// cannot close that race. Orphan cleanup remains inspection-only.
|
|
1272
|
+
return purpose === "orphan"
|
|
1273
|
+
? {
|
|
1274
|
+
id: probe.id,
|
|
1275
|
+
moduleId: spec.moduleId,
|
|
1276
|
+
label: probe.label,
|
|
1277
|
+
inspect: probe.inspect,
|
|
1278
|
+
}
|
|
1279
|
+
: probe;
|
|
1280
|
+
}
|
|
1281
|
+
function diagnosticRecordSchemaSupported(spec, collection) {
|
|
1282
|
+
const required = [
|
|
1283
|
+
spec.key,
|
|
1284
|
+
"guild_id",
|
|
1285
|
+
"channel_id",
|
|
1286
|
+
spec.pointer,
|
|
1287
|
+
"revision",
|
|
1288
|
+
"status",
|
|
1289
|
+
"created_at",
|
|
1290
|
+
"updated_at",
|
|
1291
|
+
...(spec.mode === "orphan"
|
|
1292
|
+
? ["definition_id", "attempts"]
|
|
1293
|
+
: [
|
|
1294
|
+
"name",
|
|
1295
|
+
"name_normalized",
|
|
1296
|
+
"message_content",
|
|
1297
|
+
"embeds_json",
|
|
1298
|
+
"suppress_notifications",
|
|
1299
|
+
"cleanup_channel_id",
|
|
1300
|
+
"cleanup_message_id",
|
|
1301
|
+
]),
|
|
1302
|
+
...(spec.mode === "sticky" ? ["generation", "cooldown_seconds"] : []),
|
|
1303
|
+
];
|
|
1304
|
+
return (!!collection.readableFields &&
|
|
1305
|
+
!!collection.writableFields &&
|
|
1306
|
+
!!collection.uniqueKeys &&
|
|
1307
|
+
required.every((field) => collection.readableFields?.includes(field) &&
|
|
1308
|
+
!!collection.fields[field]) &&
|
|
1309
|
+
collection.orderKeys?.some((fields) => fields.length === 1 && fields[0] === spec.key) === true &&
|
|
1310
|
+
collection.uniqueKeys.some((fields) => diagnosticRecordSame(fields, spec.mode === "orphan"
|
|
1311
|
+
? ["guild_id", "channel_id", "message_id"]
|
|
1312
|
+
: ["guild_id", spec.key])) &&
|
|
1313
|
+
(spec.mode === "orphan" ||
|
|
1314
|
+
[
|
|
1315
|
+
spec.pointer,
|
|
1316
|
+
"status",
|
|
1317
|
+
"revision",
|
|
1318
|
+
"updated_at",
|
|
1319
|
+
"cleanup_channel_id",
|
|
1320
|
+
"cleanup_message_id",
|
|
1321
|
+
...(spec.mode === "sticky" ? ["generation"] : []),
|
|
1322
|
+
].every((field) => collection.writableFields?.includes(field))));
|
|
1323
|
+
}
|
|
1324
|
+
function diagnosticRecordValid(spec, collection, row, env) {
|
|
1325
|
+
if (row.guild_id !== env.guildId ||
|
|
1326
|
+
typeof row[spec.key] !== "string" ||
|
|
1327
|
+
!row[spec.key])
|
|
1328
|
+
return false;
|
|
1329
|
+
for (const field of collection.readableFields) {
|
|
1330
|
+
const declaration = collection.fields[field];
|
|
1331
|
+
if (!declaration)
|
|
1332
|
+
return false;
|
|
1333
|
+
const value = row[field];
|
|
1334
|
+
if (value === null || value === undefined) {
|
|
1335
|
+
if (!declaration.optional)
|
|
1336
|
+
return false;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (declaration.type === "date") {
|
|
1340
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime()))
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
else if (declaration.type === "json") {
|
|
1344
|
+
if (typeof value !== "string")
|
|
1345
|
+
return false;
|
|
1346
|
+
try {
|
|
1347
|
+
JSON.parse(value);
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
else if (typeof value !== declaration.type)
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
if (!Number.isSafeInteger(row.revision) ||
|
|
1357
|
+
Number(row.revision) < 1 ||
|
|
1358
|
+
Number(row.revision) >= Number.MAX_SAFE_INTEGER)
|
|
1359
|
+
return false;
|
|
1360
|
+
if (!/^\d{17,20}$/u.test(String(row.channel_id)))
|
|
1361
|
+
return false;
|
|
1362
|
+
for (const field of [
|
|
1363
|
+
spec.pointer,
|
|
1364
|
+
"cleanup_channel_id",
|
|
1365
|
+
"cleanup_message_id",
|
|
1366
|
+
])
|
|
1367
|
+
if (row[field] != null && !/^\d{17,20}$/u.test(String(row[field])))
|
|
1368
|
+
return false;
|
|
1369
|
+
if (spec.mode === "orphan")
|
|
1370
|
+
return (Number.isSafeInteger(row.attempts) &&
|
|
1371
|
+
Number(row.attempts) >= 0 &&
|
|
1372
|
+
Number(row.attempts) < 10 &&
|
|
1373
|
+
["pending", "requires_review"].includes(String(row.status)));
|
|
1374
|
+
if (spec.mode === "sticky" &&
|
|
1375
|
+
(!Number.isSafeInteger(row.generation) ||
|
|
1376
|
+
Number(row.generation) < 1 ||
|
|
1377
|
+
Number(row.generation) >= Number.MAX_SAFE_INTEGER))
|
|
1378
|
+
return false;
|
|
1379
|
+
const states = spec.mode === "sticky"
|
|
1380
|
+
? ["active", "paused", "missing", "review", "cleanup"]
|
|
1381
|
+
: [
|
|
1382
|
+
"active",
|
|
1383
|
+
"message_missing",
|
|
1384
|
+
"needs_attention",
|
|
1385
|
+
"publishing",
|
|
1386
|
+
"deleting",
|
|
1387
|
+
];
|
|
1388
|
+
if (!states.includes(String(row.status)))
|
|
1389
|
+
return false;
|
|
1390
|
+
if (!!row.cleanup_channel_id !== !!row.cleanup_message_id)
|
|
1391
|
+
return false;
|
|
1392
|
+
try {
|
|
1393
|
+
const embeds = JSON.parse(String(row.embeds_json));
|
|
1394
|
+
if (!Array.isArray(embeds))
|
|
1395
|
+
return false;
|
|
1396
|
+
env.services
|
|
1397
|
+
.get(HELYX_SERVICE_NAMES.messageBuilder)
|
|
1398
|
+
.buildRich({
|
|
1399
|
+
...(typeof row.message_content === "string"
|
|
1400
|
+
? { content: row.message_content }
|
|
1401
|
+
: {}),
|
|
1402
|
+
embeds: embeds,
|
|
1403
|
+
suppressNotifications: row.suppress_notifications === true,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
function diagnosticRecordAudit(env, spec, fixId, key, row) {
|
|
1412
|
+
return {
|
|
1413
|
+
guildId: env.guildId,
|
|
1414
|
+
actorUserId: env.actorUserId,
|
|
1415
|
+
source: "discord",
|
|
1416
|
+
action: "diagnostic.record_repaired",
|
|
1417
|
+
correlationId: env.correlationId,
|
|
1418
|
+
idempotencyKey: `diag:${env.correlationId}:${fixId}:${key}:${String(row.revision)}`,
|
|
1419
|
+
targetType: spec.collection,
|
|
1420
|
+
targetId: key,
|
|
1421
|
+
metadata: { fixId, previousRevision: row.revision },
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function diagnosticRecordSame(left, right) {
|
|
1425
|
+
if (left instanceof Date || right instanceof Date)
|
|
1426
|
+
return (left instanceof Date &&
|
|
1427
|
+
right instanceof Date &&
|
|
1428
|
+
left.getTime() === right.getTime());
|
|
1429
|
+
if (left === null ||
|
|
1430
|
+
right === null ||
|
|
1431
|
+
typeof left !== "object" ||
|
|
1432
|
+
typeof right !== "object")
|
|
1433
|
+
return left === right;
|
|
1434
|
+
if (Array.isArray(left) || Array.isArray(right))
|
|
1435
|
+
return (Array.isArray(left) &&
|
|
1436
|
+
Array.isArray(right) &&
|
|
1437
|
+
left.length === right.length &&
|
|
1438
|
+
left.every((value, index) => diagnosticRecordSame(value, right[index])));
|
|
1439
|
+
const leftRecord = left;
|
|
1440
|
+
const rightRecord = right;
|
|
1441
|
+
const keys = Object.keys(leftRecord);
|
|
1442
|
+
return (keys.length === Object.keys(rightRecord).length &&
|
|
1443
|
+
keys.every((key) => Object.hasOwn(rightRecord, key) &&
|
|
1444
|
+
diagnosticRecordSame(leftRecord[key], rightRecord[key])));
|
|
1445
|
+
}
|
|
1446
|
+
function createNativeDiagnosticResources(client, guildId) {
|
|
1447
|
+
const incomplete = (message) => ({
|
|
1448
|
+
kind: "incomplete",
|
|
1449
|
+
message,
|
|
1450
|
+
});
|
|
1451
|
+
const human = (message) => ({
|
|
1452
|
+
kind: "human",
|
|
1453
|
+
message,
|
|
1454
|
+
});
|
|
1455
|
+
const validId = (value) => /^\d{17,20}$/u.test(value);
|
|
1456
|
+
const permissionName = (value) => value.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
1457
|
+
async function freshGuild() {
|
|
1458
|
+
if (!validId(guildId))
|
|
1459
|
+
throw new Error("Invalid diagnostic server scope");
|
|
1460
|
+
const guild = await client.guilds.fetch({
|
|
1461
|
+
guild: guildId,
|
|
1462
|
+
force: true,
|
|
1463
|
+
cache: true,
|
|
1464
|
+
});
|
|
1465
|
+
if (guild.id !== guildId)
|
|
1466
|
+
throw new Error("Diagnostic server scope changed");
|
|
1467
|
+
return guild;
|
|
1468
|
+
}
|
|
1469
|
+
async function freshMember(guild, userId) {
|
|
1470
|
+
if (!validId(userId))
|
|
1471
|
+
throw new Error("Invalid diagnostic member");
|
|
1472
|
+
// Updating the role cache ensures newly assigned roles are represented by
|
|
1473
|
+
// member.roles.cache. Only the returned fresh role collection grants bits.
|
|
1474
|
+
const roles = await guild.roles.fetch(undefined, { cache: true });
|
|
1475
|
+
const member = await guild.members.fetch({
|
|
1476
|
+
user: userId,
|
|
1477
|
+
force: true,
|
|
1478
|
+
cache: false,
|
|
1479
|
+
});
|
|
1480
|
+
if (member.id !== userId || member.guild.id !== guildId)
|
|
1481
|
+
throw new Error("Diagnostic member scope changed");
|
|
1482
|
+
const membership = new Set(member.roles.cache.keys());
|
|
1483
|
+
const currentRoles = [...roles.values()].filter((role) => role.id === guildId || membership.has(role.id));
|
|
1484
|
+
const permissions = new PermissionsBitField(currentRoles.reduce((bits, role) => bits | role.permissions.bitfield, 0n));
|
|
1485
|
+
return { member, roles, currentRoles, permissions, membership };
|
|
1486
|
+
}
|
|
1487
|
+
async function freshBot(guild) {
|
|
1488
|
+
if (!client.user)
|
|
1489
|
+
throw new Error("Diagnostic bot identity is unavailable");
|
|
1490
|
+
return freshMember(guild, client.user.id);
|
|
1491
|
+
}
|
|
1492
|
+
// Compute from fresh member roles and freshly fetched channel overwrites,
|
|
1493
|
+
// rather than a possibly stale member.permissions getter.
|
|
1494
|
+
function channelPermissions(channel, bot) {
|
|
1495
|
+
if (bot.member.id === channel.guild.ownerId ||
|
|
1496
|
+
bot.permissions.has(PermissionFlagsBits.Administrator))
|
|
1497
|
+
return new PermissionsBitField(PermissionsBitField.All);
|
|
1498
|
+
let bits = bot.permissions.bitfield;
|
|
1499
|
+
const everyone = channel.permissionOverwrites.cache.get(guildId);
|
|
1500
|
+
if (everyone)
|
|
1501
|
+
bits = (bits & ~everyone.deny.bitfield) | everyone.allow.bitfield;
|
|
1502
|
+
let deny = 0n;
|
|
1503
|
+
let allow = 0n;
|
|
1504
|
+
for (const role of bot.currentRoles) {
|
|
1505
|
+
if (role.id === guildId)
|
|
1506
|
+
continue;
|
|
1507
|
+
const overwrite = channel.permissionOverwrites.cache.get(role.id);
|
|
1508
|
+
if (overwrite) {
|
|
1509
|
+
deny |= overwrite.deny.bitfield;
|
|
1510
|
+
allow |= overwrite.allow.bitfield;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
bits = (bits & ~deny) | allow;
|
|
1514
|
+
const member = channel.permissionOverwrites.cache.get(bot.member.id);
|
|
1515
|
+
if (member)
|
|
1516
|
+
bits = (bits & ~member.deny.bitfield) | member.allow.bitfield;
|
|
1517
|
+
return new PermissionsBitField(bits);
|
|
1518
|
+
}
|
|
1519
|
+
async function freshChannel(channelId) {
|
|
1520
|
+
if (!validId(channelId))
|
|
1521
|
+
return null;
|
|
1522
|
+
const channel = await client.channels.fetch(channelId, {
|
|
1523
|
+
force: true,
|
|
1524
|
+
cache: false,
|
|
1525
|
+
});
|
|
1526
|
+
if (!channel ||
|
|
1527
|
+
channel.isDMBased() ||
|
|
1528
|
+
!("guild" in channel) ||
|
|
1529
|
+
channel.guild.id !== guildId)
|
|
1530
|
+
return null;
|
|
1531
|
+
return channel;
|
|
1532
|
+
}
|
|
1533
|
+
return {
|
|
1534
|
+
async actor(userId) {
|
|
1535
|
+
const guild = await freshGuild();
|
|
1536
|
+
const current = await freshMember(guild, userId);
|
|
1537
|
+
return {
|
|
1538
|
+
userId,
|
|
1539
|
+
roleIds: current.currentRoles.map((role) => role.id),
|
|
1540
|
+
permissions: current.permissions.toArray().map(permissionName),
|
|
1541
|
+
isServerOwner: guild.ownerId === userId,
|
|
1542
|
+
isAdministrator: current.permissions.has(PermissionFlagsBits.Administrator),
|
|
1543
|
+
highestRolePosition: Math.max(0, ...current.currentRoles.map((role) => role.position)),
|
|
1544
|
+
};
|
|
1545
|
+
},
|
|
1546
|
+
async message(channelId, messageId) {
|
|
1547
|
+
if (!validId(channelId) || !validId(messageId))
|
|
1548
|
+
return "unknown";
|
|
1549
|
+
try {
|
|
1550
|
+
const guild = await freshGuild();
|
|
1551
|
+
const bot = await freshBot(guild);
|
|
1552
|
+
const channel = await freshChannel(channelId);
|
|
1553
|
+
// A thread can require membership, parent access and archive handling;
|
|
1554
|
+
// these checks never turn an unsupported thread into deletion evidence.
|
|
1555
|
+
if (!channel ||
|
|
1556
|
+
(channel.type !== ChannelType.GuildText &&
|
|
1557
|
+
channel.type !== ChannelType.GuildAnnouncement))
|
|
1558
|
+
return "unknown";
|
|
1559
|
+
const permissions = channelPermissions(channel, bot);
|
|
1560
|
+
if (!permissions.has(PermissionFlagsBits.ViewChannel) ||
|
|
1561
|
+
!permissions.has(PermissionFlagsBits.ReadMessageHistory))
|
|
1562
|
+
return "unknown";
|
|
1563
|
+
try {
|
|
1564
|
+
const message = await channel.messages.fetch({
|
|
1565
|
+
message: messageId,
|
|
1566
|
+
force: true,
|
|
1567
|
+
cache: false,
|
|
1568
|
+
});
|
|
1569
|
+
return message.id === messageId &&
|
|
1570
|
+
message.channelId === channelId &&
|
|
1571
|
+
// An uncached REST message need not carry guild_id. Its freshly
|
|
1572
|
+
// fetched channel above proves server ownership; a conflicting
|
|
1573
|
+
// non-null guild ID still fails closed.
|
|
1574
|
+
(message.guildId === null || message.guildId === guildId) &&
|
|
1575
|
+
message.author.id === client.user?.id
|
|
1576
|
+
? "present"
|
|
1577
|
+
: "unknown";
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
// Only Discord's exact Unknown Message response, after successful
|
|
1581
|
+
// scoped channel/access checks, is evidence of absence.
|
|
1582
|
+
if (!(error instanceof DiscordAPIError) || error.code !== 10008)
|
|
1583
|
+
return "unknown";
|
|
1584
|
+
const currentGuild = await freshGuild();
|
|
1585
|
+
const currentBot = await freshBot(currentGuild);
|
|
1586
|
+
const currentChannel = await freshChannel(channelId);
|
|
1587
|
+
if (!currentChannel ||
|
|
1588
|
+
(currentChannel.type !== ChannelType.GuildText &&
|
|
1589
|
+
currentChannel.type !== ChannelType.GuildAnnouncement))
|
|
1590
|
+
return "unknown";
|
|
1591
|
+
const currentPermissions = channelPermissions(currentChannel, currentBot);
|
|
1592
|
+
return currentPermissions.has(PermissionFlagsBits.ViewChannel) &&
|
|
1593
|
+
currentPermissions.has(PermissionFlagsBits.ReadMessageHistory)
|
|
1594
|
+
? "absent"
|
|
1595
|
+
: "unknown";
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
catch {
|
|
1599
|
+
return "unknown";
|
|
1600
|
+
}
|
|
1601
|
+
},
|
|
1602
|
+
async channel(channelId, types, requiredPermissions) {
|
|
1603
|
+
try {
|
|
1604
|
+
const guild = await freshGuild();
|
|
1605
|
+
const bot = await freshBot(guild);
|
|
1606
|
+
const channel = await freshChannel(channelId);
|
|
1607
|
+
if (!channel)
|
|
1608
|
+
return incomplete("The configured channel could not be checked. Review its access and selection in Settings.");
|
|
1609
|
+
if (channel.isThread())
|
|
1610
|
+
return incomplete("This channel check does not support threads. Review the channel selection in Settings.");
|
|
1611
|
+
const type = channel.type === ChannelType.GuildText
|
|
1612
|
+
? "guild_text"
|
|
1613
|
+
: channel.type === ChannelType.GuildAnnouncement
|
|
1614
|
+
? "guild_announcement"
|
|
1615
|
+
: channel.type === ChannelType.GuildCategory
|
|
1616
|
+
? "guild_category"
|
|
1617
|
+
: channel.type === ChannelType.GuildForum
|
|
1618
|
+
? "guild_forum"
|
|
1619
|
+
: channel.type === ChannelType.GuildMedia
|
|
1620
|
+
? "guild_media"
|
|
1621
|
+
: undefined;
|
|
1622
|
+
if (!type)
|
|
1623
|
+
return incomplete("The selected channel type cannot be checked here. Review its selection in Settings.");
|
|
1624
|
+
if (types.length && !types.includes(type))
|
|
1625
|
+
return human("The configured channel has the wrong type. Choose a supported channel in Settings.");
|
|
1626
|
+
const permissions = channelPermissions(channel, bot);
|
|
1627
|
+
if (!permissions.has(PermissionFlagsBits.ViewChannel))
|
|
1628
|
+
return human("Helyx cannot view the configured channel. Update its channel permissions.");
|
|
1629
|
+
const known = new Set(Object.keys(PermissionFlagsBits).map(permissionName));
|
|
1630
|
+
if (requiredPermissions.some((name) => !known.has(name)))
|
|
1631
|
+
return incomplete("A required channel permission could not be checked. Ask support to review the module requirements.");
|
|
1632
|
+
const allowed = new Set(permissions.toArray().map(permissionName));
|
|
1633
|
+
if (requiredPermissions.some((name) => !allowed.has(name)))
|
|
1634
|
+
return human("Helyx is missing a required channel permission. Review the module's Permissions guide.");
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
catch {
|
|
1638
|
+
return incomplete("The configured channel could not be checked. Retry later or review Helyx's access.");
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
async role(roleId, managed) {
|
|
1642
|
+
if (!validId(roleId))
|
|
1643
|
+
return incomplete("The configured role could not be checked. Review its selection in Settings.");
|
|
1644
|
+
try {
|
|
1645
|
+
const guild = await freshGuild();
|
|
1646
|
+
const bot = await freshBot(guild);
|
|
1647
|
+
const role = bot.roles.get(roleId);
|
|
1648
|
+
if (!role)
|
|
1649
|
+
return human("The configured role no longer exists. Choose a role in Settings.");
|
|
1650
|
+
if (role.guild.id !== guildId)
|
|
1651
|
+
return incomplete("The configured role does not belong to this server.");
|
|
1652
|
+
if (!managed)
|
|
1653
|
+
return null;
|
|
1654
|
+
if (role.id === guildId || role.managed)
|
|
1655
|
+
return human("Helyx cannot assign the selected role. Choose a role that is not managed by Discord or another integration.");
|
|
1656
|
+
if (!bot.permissions.has(PermissionFlagsBits.ManageRoles))
|
|
1657
|
+
return human("Helyx needs Manage Roles to use the configured role.");
|
|
1658
|
+
const highest = bot.currentRoles.reduce((selected, current) => !selected || current.comparePositionTo(selected) > 0
|
|
1659
|
+
? current
|
|
1660
|
+
: selected, undefined);
|
|
1661
|
+
if (!highest || role.comparePositionTo(highest) >= 0)
|
|
1662
|
+
return human("Move Helyx's highest role above the configured role in Discord's role list.");
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
return incomplete("The configured role could not be checked. Retry later or review Helyx's role access.");
|
|
1667
|
+
}
|
|
1668
|
+
},
|
|
1669
|
+
capabilities() {
|
|
1670
|
+
return {
|
|
1671
|
+
guildMembers: client.options.intents.has(GatewayIntentBits.GuildMembers),
|
|
1672
|
+
messageContent: client.options.intents.has(GatewayIntentBits.MessageContent),
|
|
1673
|
+
};
|
|
1674
|
+
},
|
|
1675
|
+
async guildCommands() {
|
|
1676
|
+
if (!validId(guildId) || !client.application?.id)
|
|
1677
|
+
throw new Error("Diagnostic command observation is unavailable");
|
|
1678
|
+
const observed = await client.rest.get(Routes.applicationGuildCommands(client.application.id, guildId));
|
|
1679
|
+
const isCommand = (value) => {
|
|
1680
|
+
if (typeof value !== "object" ||
|
|
1681
|
+
value === null ||
|
|
1682
|
+
Array.isArray(value) ||
|
|
1683
|
+
!("name" in value) ||
|
|
1684
|
+
typeof value.name !== "string")
|
|
1685
|
+
return false;
|
|
1686
|
+
if (("id" in value && typeof value.id !== "string") ||
|
|
1687
|
+
("application_id" in value &&
|
|
1688
|
+
typeof value.application_id !== "string") ||
|
|
1689
|
+
("guild_id" in value && typeof value.guild_id !== "string") ||
|
|
1690
|
+
("version" in value && typeof value.version !== "string"))
|
|
1691
|
+
return false;
|
|
1692
|
+
return ((!("type" in value) || typeof value.type === "number") &&
|
|
1693
|
+
(!("guild_id" in value) || value.guild_id === guildId) &&
|
|
1694
|
+
(!("application_id" in value) ||
|
|
1695
|
+
value.application_id === client.application?.id));
|
|
1696
|
+
};
|
|
1697
|
+
if (!Array.isArray(observed) || !observed.every(isCommand))
|
|
1698
|
+
throw new Error("Discord returned an invalid command observation");
|
|
1699
|
+
return {
|
|
1700
|
+
definitionHash: guildCommandDefinitionHash(observed),
|
|
1701
|
+
rootCount: observed.length,
|
|
1702
|
+
};
|
|
1703
|
+
},
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
const DIAGNOSTIC_APPROVED_RECORD_MODULES = new Set([
|
|
1707
|
+
"helyx.sticky-messages",
|
|
1708
|
+
"helyx.message-embedder",
|
|
1709
|
+
]);
|
|
1710
|
+
const DIAGNOSTIC_WRITE_CHANNEL_SETTINGS = new Set([
|
|
1711
|
+
"helyx.welcome:channelId",
|
|
1712
|
+
"helyx.birthdays:announcementChannelId",
|
|
1713
|
+
"helyx.levels:announcementChannelId",
|
|
1714
|
+
"helyx.starboard:destinationChannelId",
|
|
1715
|
+
"helyx.suggestions:channelId",
|
|
1716
|
+
"helyx.feedback:channelId",
|
|
1717
|
+
"helyx.feedback:panelChannelId",
|
|
1718
|
+
"helyx.logging:channelId",
|
|
1719
|
+
]);
|
|
1720
|
+
function diagnosticSettingValue(value, path) {
|
|
1721
|
+
// Prefer an explicitly stored key, then support nested configuration paths.
|
|
1722
|
+
if (Object.hasOwn(value, path))
|
|
1723
|
+
return value[path];
|
|
1724
|
+
let current = value;
|
|
1725
|
+
for (const key of path.split(".")) {
|
|
1726
|
+
if (typeof current !== "object" ||
|
|
1727
|
+
current === null ||
|
|
1728
|
+
!Object.hasOwn(current, key))
|
|
1729
|
+
return undefined;
|
|
1730
|
+
current = current[key];
|
|
1731
|
+
}
|
|
1732
|
+
return current;
|
|
1733
|
+
}
|
|
1734
|
+
function diagnosticSettingVisible(setting, settings, value, seen = new Set()) {
|
|
1735
|
+
const condition = setting.visibleWhen;
|
|
1736
|
+
if (!condition)
|
|
1737
|
+
return true;
|
|
1738
|
+
if (seen.has(setting.key))
|
|
1739
|
+
return false;
|
|
1740
|
+
seen.add(setting.key);
|
|
1741
|
+
const parent = settings.find((candidate) => candidate.key === condition.field);
|
|
1742
|
+
if (parent && !diagnosticSettingVisible(parent, settings, value, seen))
|
|
1743
|
+
return false;
|
|
1744
|
+
const stored = diagnosticSettingValue(value, condition.field);
|
|
1745
|
+
const actual = stored === undefined && parent && "default" in parent
|
|
1746
|
+
? parent.default
|
|
1747
|
+
: stored;
|
|
1748
|
+
return actual === condition.equals;
|
|
1749
|
+
}
|
|
1750
|
+
function diagnosticActiveResourceSetting(moduleId, setting, settings, value) {
|
|
1751
|
+
if (!diagnosticSettingVisible(setting, settings, value))
|
|
1752
|
+
return false;
|
|
1753
|
+
// Levels predates visibleWhen for milestones. Unused or disabled milestone
|
|
1754
|
+
// roles must not generate errors or acquire additional Discord permissions.
|
|
1755
|
+
if (moduleId === "helyx.levels") {
|
|
1756
|
+
const reward = /^rewardRole([1-9]|10)$/u.exec(setting.key);
|
|
1757
|
+
if (reward) {
|
|
1758
|
+
const milestone = Number(reward[1]);
|
|
1759
|
+
const count = diagnosticSettingValue(value, "rewardMilestoneCount") ?? 5;
|
|
1760
|
+
if (typeof count !== "number" || milestone > count)
|
|
1761
|
+
return false;
|
|
1762
|
+
return (diagnosticSettingValue(value, `rewardRoleMode${milestone}`) ===
|
|
1763
|
+
"existing");
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
function diagnosticReadConfiguration(entry) {
|
|
1769
|
+
const saved = structuredClone(entry.configuration?.value ?? {});
|
|
1770
|
+
// validate is the pure schema parser. Never invoke validateUpdate,
|
|
1771
|
+
// validateActivation, lifecycle, managed-resource or recovery hooks here.
|
|
1772
|
+
return entry.module.configuration?.validate(saved) ?? saved;
|
|
1773
|
+
}
|
|
1774
|
+
function moduleProbes(entry, services, guildId, resources) {
|
|
1775
|
+
const module = entry.module;
|
|
1776
|
+
const moduleId = module.manifest.id;
|
|
1777
|
+
const name = module.manifest.name;
|
|
1778
|
+
const settings = module.manifest.dashboard?.settings ?? [];
|
|
1779
|
+
const probes = [];
|
|
1780
|
+
const add = (id, label, inspect) => {
|
|
1781
|
+
probes.push({
|
|
1782
|
+
id: `${moduleId}.${id}`,
|
|
1783
|
+
moduleId,
|
|
1784
|
+
label: `${name} ${label}`,
|
|
1785
|
+
inspect: async () => {
|
|
1786
|
+
const current = await services
|
|
1787
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
1788
|
+
.get(guildId, moduleId);
|
|
1789
|
+
if (!sameConfiguration(current, entry.configuration))
|
|
1790
|
+
return {
|
|
1791
|
+
kind: "human",
|
|
1792
|
+
message: `${name} settings changed during this run. Run /diag again to check the newer setup.`,
|
|
1793
|
+
};
|
|
1794
|
+
return inspect();
|
|
1795
|
+
},
|
|
1796
|
+
});
|
|
1797
|
+
};
|
|
1798
|
+
add("availability", "availability", async () => {
|
|
1799
|
+
if (!services.has(HELYX_SERVICE_NAMES.moduleAvailability)) {
|
|
1800
|
+
return {
|
|
1801
|
+
kind: "incomplete",
|
|
1802
|
+
message: `${name} availability could not be checked.`,
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
if (!(await services
|
|
1806
|
+
.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
1807
|
+
.isAvailable(moduleId))) {
|
|
1808
|
+
return {
|
|
1809
|
+
kind: "human",
|
|
1810
|
+
message: `${name} is currently unavailable. Ask the operator to review its availability.`,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
return null;
|
|
1814
|
+
});
|
|
1815
|
+
add("entitlement", "access", async () => (await canAccessModule(services, guildId, module))
|
|
1816
|
+
? null
|
|
1817
|
+
: {
|
|
1818
|
+
kind: "human",
|
|
1819
|
+
message: `${name} needs Premium access assigned to this server. Review Billing or use /activate.`,
|
|
1820
|
+
});
|
|
1821
|
+
if (module.configuration) {
|
|
1822
|
+
add("configuration", "settings", () => {
|
|
1823
|
+
if (entry.configuration &&
|
|
1824
|
+
entry.configuration.schemaVersion !==
|
|
1825
|
+
module.configuration.schemaVersion) {
|
|
1826
|
+
return {
|
|
1827
|
+
kind: "incomplete",
|
|
1828
|
+
message: `${name} settings use a different version. Ask the operator to check module updates.`,
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
try {
|
|
1832
|
+
diagnosticReadConfiguration(entry);
|
|
1833
|
+
}
|
|
1834
|
+
catch {
|
|
1835
|
+
return {
|
|
1836
|
+
kind: "human",
|
|
1837
|
+
message: `${name} has missing or invalid settings. Open its Settings page and save corrected values.`,
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
return null;
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
const required = new Set(module.requiredDiscordCapabilities ?? []);
|
|
1844
|
+
for (const capability of required) {
|
|
1845
|
+
add(`capability.${capability}`, "Discord access", () => {
|
|
1846
|
+
const available = resources.capabilities();
|
|
1847
|
+
const enabled = capability === "guild-members"
|
|
1848
|
+
? available.guildMembers
|
|
1849
|
+
: available.messageContent;
|
|
1850
|
+
return enabled
|
|
1851
|
+
? null
|
|
1852
|
+
: {
|
|
1853
|
+
kind: "human",
|
|
1854
|
+
message: `${name} needs the ${capability === "guild-members" ? "Server Members" : "Message Content"} intent. Ask the bot operator to enable it in Discord and the bot configuration.`,
|
|
1855
|
+
};
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
for (const setting of settings) {
|
|
1859
|
+
// Selected options can require an intent even when the entire module does
|
|
1860
|
+
// not. Do not treat an inactive option as an active Discord requirement.
|
|
1861
|
+
if (setting.type === "select" &&
|
|
1862
|
+
setting.options.some((option) => option.requiredDiscordCapability)) {
|
|
1863
|
+
add(`setting-capability.${setting.key}`, setting.label, () => {
|
|
1864
|
+
let value;
|
|
1865
|
+
try {
|
|
1866
|
+
value = diagnosticReadConfiguration(entry);
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
return {
|
|
1870
|
+
kind: "incomplete",
|
|
1871
|
+
message: `${name} option access could not be checked until its settings are corrected.`,
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
if (!diagnosticSettingVisible(setting, settings, value))
|
|
1875
|
+
return null;
|
|
1876
|
+
const selected = diagnosticSettingValue(value, setting.key) ?? setting.default;
|
|
1877
|
+
const capability = setting.options.find((option) => option.value === selected)?.requiredDiscordCapability;
|
|
1878
|
+
if (capability === "message-content" &&
|
|
1879
|
+
!resources.capabilities().messageContent) {
|
|
1880
|
+
return {
|
|
1881
|
+
kind: "human",
|
|
1882
|
+
message: `${name} ${setting.label} needs Message Content access. Change that option or ask the bot operator to enable the intent.`,
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
return null;
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (setting.type !== "discord-channel" && setting.type !== "discord-role")
|
|
1889
|
+
continue;
|
|
1890
|
+
add(`resource.${setting.key}`, setting.label, async () => {
|
|
1891
|
+
if (module.configuration &&
|
|
1892
|
+
entry.configuration &&
|
|
1893
|
+
entry.configuration.schemaVersion !== module.configuration.schemaVersion) {
|
|
1894
|
+
return {
|
|
1895
|
+
kind: "incomplete",
|
|
1896
|
+
message: `${name} configured channels and roles need compatible settings before they can be checked.`,
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
let value;
|
|
1900
|
+
try {
|
|
1901
|
+
value = diagnosticReadConfiguration(entry);
|
|
1902
|
+
}
|
|
1903
|
+
catch {
|
|
1904
|
+
return {
|
|
1905
|
+
kind: "incomplete",
|
|
1906
|
+
message: `${name} configured channels and roles could not be checked until its settings are corrected.`,
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
if (!diagnosticActiveResourceSetting(moduleId, setting, settings, value))
|
|
1910
|
+
return null;
|
|
1911
|
+
const selected = diagnosticSettingValue(value, setting.key);
|
|
1912
|
+
const ids = selected === "" || selected === null || selected === undefined
|
|
1913
|
+
? []
|
|
1914
|
+
: Array.isArray(selected)
|
|
1915
|
+
? selected
|
|
1916
|
+
: [selected];
|
|
1917
|
+
if (!ids.length)
|
|
1918
|
+
return setting.required || (setting.minItems ?? 0) > 0
|
|
1919
|
+
? {
|
|
1920
|
+
kind: "human",
|
|
1921
|
+
message: `${name} needs a value for ${setting.label}. Choose it in Settings.`,
|
|
1922
|
+
}
|
|
1923
|
+
: null;
|
|
1924
|
+
if (ids.length > 100)
|
|
1925
|
+
return {
|
|
1926
|
+
kind: "incomplete",
|
|
1927
|
+
message: `${name} has too many configured resources for this check. Review its Settings.`,
|
|
1928
|
+
};
|
|
1929
|
+
if (ids.some((id) => typeof id !== "string" || !/^\d{17,20}$/u.test(id))) {
|
|
1930
|
+
return {
|
|
1931
|
+
kind: "human",
|
|
1932
|
+
message: `${name} ${setting.label} contains an invalid Discord selection. Choose it again in Settings.`,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
for (const id of ids) {
|
|
1936
|
+
const issue = setting.type === "discord-channel"
|
|
1937
|
+
? await resources.channel(id, setting.channelTypes, DIAGNOSTIC_WRITE_CHANNEL_SETTINGS.has(`${moduleId}:${setting.key}`)
|
|
1938
|
+
? ["VIEW_CHANNEL", "SEND_MESSAGES"]
|
|
1939
|
+
: ["VIEW_CHANNEL"])
|
|
1940
|
+
: await resources.role(id, (moduleId === "helyx.welcome" && setting.key === "roleId") ||
|
|
1941
|
+
(moduleId === "helyx.levels" &&
|
|
1942
|
+
/^rewardRole([1-9]|10)$/u.test(setting.key)));
|
|
1943
|
+
if (issue)
|
|
1944
|
+
return issue;
|
|
1945
|
+
}
|
|
1946
|
+
return null;
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
if (entry.customer &&
|
|
1950
|
+
Object.keys(module.records ?? {}).length > 0 &&
|
|
1951
|
+
!DIAGNOSTIC_APPROVED_RECORD_MODULES.has(moduleId)) {
|
|
1952
|
+
add("record-coverage", "saved data", () => ({
|
|
1953
|
+
kind: "incomplete",
|
|
1954
|
+
message: `${name} saved-record checks are not supported by this version of /diag. Its stored data was left unchanged.`,
|
|
1955
|
+
}));
|
|
1956
|
+
}
|
|
1957
|
+
// health() has no guild argument. Current hooks include cross-server failure
|
|
1958
|
+
// trackers or external provider checks; none are approved server-only probes.
|
|
1959
|
+
// Do not invoke them or turn an unrelated server's failure into this report.
|
|
1960
|
+
return probes;
|
|
1961
|
+
}
|
|
1962
|
+
//# sourceMappingURL=diagnostic-command.js.map
|