@helyx/bot 0.4.36 → 0.4.38
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 +20 -0
- package/README.md +17 -2
- package/dist/cloud-management-module.js +275 -206
- package/dist/command-registration-policy.d.ts +8 -0
- package/dist/command-registration-policy.js +39 -0
- package/dist/command-sync.d.ts +5 -1
- package/dist/command-sync.js +67 -3
- package/dist/control-server.d.ts +15 -3
- package/dist/control-server.js +34 -48
- package/dist/dashboard-commands.js +5 -1
- package/dist/developer-operations-module.d.ts +2 -0
- package/dist/developer-operations-module.js +65 -4
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-rest.d.ts +5 -0
- package/dist/discord-rest.js +9 -0
- package/dist/events.js +4 -2
- package/dist/first-time-command-setup.d.ts +25 -0
- package/dist/first-time-command-setup.js +22 -0
- package/dist/guild-command-control-routes.d.ts +5 -0
- package/dist/guild-command-control-routes.js +191 -0
- package/dist/guild-command-discord-client.d.ts +21 -0
- package/dist/guild-command-discord-client.js +50 -0
- package/dist/guild-command-projection.d.ts +46 -0
- package/dist/guild-command-projection.js +254 -0
- package/dist/guild-command-read-service.d.ts +201 -0
- package/dist/guild-command-read-service.js +272 -0
- package/dist/guild-command-release.d.ts +19 -0
- package/dist/guild-command-release.js +47 -0
- package/dist/guild-command-worker.d.ts +49 -0
- package/dist/guild-command-worker.js +443 -0
- package/dist/guilds.d.ts +1 -1
- package/dist/guilds.js +41 -26
- package/dist/index.js +75 -4
- package/dist/interactions.d.ts +15 -4
- package/dist/interactions.js +285 -70
- package/dist/management-module.js +117 -41
- package/dist/module-logging.d.ts +1 -0
- package/dist/module-logging.js +5 -5
- package/dist/module-status-control.d.ts +19 -0
- package/dist/module-status-control.js +69 -0
- package/dist/modules.d.ts +2 -0
- package/dist/modules.js +21 -2
- package/dist/nexus-commands.d.ts +1 -12
- package/dist/nexus-commands.js +1 -99
- package/dist/oauth-module.js +134 -88
- package/dist/permissions-module.js +112 -39
- package/dist/privacy-module.d.ts +2 -0
- package/dist/privacy-module.js +60 -0
- package/dist/scheduled-task-runner.js +36 -1
- package/dist/shard-contracts.d.ts +24 -0
- package/dist/shard-contracts.js +20 -0
- package/dist/shard-router.d.ts +1 -0
- package/dist/shard-router.js +7 -0
- package/dist/shard-supervisor.d.ts +2 -1
- package/dist/shard-supervisor.js +94 -16
- package/dist/shard-worker.d.ts +6 -1
- package/dist/shard-worker.js +9 -1
- package/dist/shared-commands.d.ts +13 -0
- package/dist/shared-commands.js +163 -0
- package/dist/support-module.js +51 -51
- package/dist/trivia-data-provider.d.ts +26 -0
- package/dist/trivia-data-provider.js +51 -0
- package/package.json +5 -4
- package/dist/oauth-module.test.d.ts +0 -2
- package/dist/oauth-module.test.js +0 -172
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { GuildCommandBudgetExceededError, GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, } from "@helyx/database";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { diffDryRunGuildCommands, diffGuildCommands, projectGuildCommands, } from "./guild-command-projection.js";
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
5
|
+
const DEFAULT_LEASE_MS = 45_000;
|
|
6
|
+
const DEFAULT_CLAIM_LIMIT = 4;
|
|
7
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
8
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
|
|
9
|
+
export class GuildCommandWorker {
|
|
10
|
+
#repository;
|
|
11
|
+
#database;
|
|
12
|
+
#modules;
|
|
13
|
+
#logger;
|
|
14
|
+
#workerId;
|
|
15
|
+
#fencingToken;
|
|
16
|
+
#pollIntervalMs;
|
|
17
|
+
#leaseMs;
|
|
18
|
+
#claimLimit;
|
|
19
|
+
#concurrency;
|
|
20
|
+
#stopTimeoutMs;
|
|
21
|
+
#discord;
|
|
22
|
+
#internalGuildId;
|
|
23
|
+
#controller = new AbortController();
|
|
24
|
+
#active = new Set();
|
|
25
|
+
#timer;
|
|
26
|
+
#claiming;
|
|
27
|
+
#stopping = false;
|
|
28
|
+
constructor(input) {
|
|
29
|
+
if (input.fencingToken < 1n)
|
|
30
|
+
throw new Error("Guild command worker fencing token must be positive");
|
|
31
|
+
this.#database = input.database;
|
|
32
|
+
this.#repository = new GuildCommandRepository(input.database);
|
|
33
|
+
this.#modules = input.modules;
|
|
34
|
+
this.#logger = input.logger;
|
|
35
|
+
this.#workerId = input.workerId ?? `guild-command:${randomUUID()}`;
|
|
36
|
+
this.#fencingToken = input.fencingToken;
|
|
37
|
+
this.#discord = input.discord ?? null;
|
|
38
|
+
this.#internalGuildId = input.internalGuildId;
|
|
39
|
+
this.#pollIntervalMs = boundedInteger(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 100, 60_000, "poll interval");
|
|
40
|
+
this.#leaseMs = boundedInteger(input.leaseMs ?? DEFAULT_LEASE_MS, 5_000, 300_000, "lease duration");
|
|
41
|
+
this.#claimLimit = boundedInteger(input.claimLimit ?? DEFAULT_CLAIM_LIMIT, 1, 25, "claim limit");
|
|
42
|
+
this.#concurrency = boundedInteger(input.concurrency ?? DEFAULT_CONCURRENCY, 1, 8, "worker concurrency");
|
|
43
|
+
this.#stopTimeoutMs = boundedInteger(input.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 100, 30_000, "stop timeout");
|
|
44
|
+
}
|
|
45
|
+
start() {
|
|
46
|
+
if (this.#timer || this.#stopping)
|
|
47
|
+
throw new Error("Guild command worker is already started or stopped");
|
|
48
|
+
this.#runSoon();
|
|
49
|
+
this.#timer = setInterval(() => this.#runSoon(), this.#pollIntervalMs);
|
|
50
|
+
this.#timer.unref();
|
|
51
|
+
}
|
|
52
|
+
async runOnce() {
|
|
53
|
+
if (this.#stopping)
|
|
54
|
+
return;
|
|
55
|
+
const capacity = Math.max(0, this.#concurrency - this.#active.size);
|
|
56
|
+
if (capacity === 0)
|
|
57
|
+
return;
|
|
58
|
+
const requests = await this.#repository.claimDue({
|
|
59
|
+
workerId: this.#workerId,
|
|
60
|
+
fencingToken: this.#fencingToken,
|
|
61
|
+
leaseMs: this.#leaseMs,
|
|
62
|
+
limit: Math.min(capacity, this.#claimLimit),
|
|
63
|
+
});
|
|
64
|
+
for (const request of requests) {
|
|
65
|
+
const task = this.#execute(request)
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
this.#logger.error({
|
|
68
|
+
event: "guild_commands.reconcile_failed",
|
|
69
|
+
requestId: request.requestId,
|
|
70
|
+
guildId: request.guildId,
|
|
71
|
+
errorName: error instanceof Error ? error.name : "Error",
|
|
72
|
+
}, "Guild command reconciliation failed");
|
|
73
|
+
})
|
|
74
|
+
.finally(() => this.#active.delete(task));
|
|
75
|
+
this.#active.add(task);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async stop() {
|
|
79
|
+
if (!this.#stopping) {
|
|
80
|
+
this.#stopping = true;
|
|
81
|
+
if (this.#timer)
|
|
82
|
+
clearInterval(this.#timer);
|
|
83
|
+
this.#timer = undefined;
|
|
84
|
+
this.#controller.abort();
|
|
85
|
+
}
|
|
86
|
+
const active = [...this.#active];
|
|
87
|
+
if (this.#claiming)
|
|
88
|
+
active.push(this.#claiming);
|
|
89
|
+
if (active.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const drained = await Promise.race([
|
|
92
|
+
Promise.allSettled(active).then(() => true),
|
|
93
|
+
wait(this.#stopTimeoutMs).then(() => false),
|
|
94
|
+
]);
|
|
95
|
+
if (!drained) {
|
|
96
|
+
this.#logger.warn({ event: "guild_commands.worker_stop_deadline_exceeded" }, "Guild command worker is still draining");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async getHealth() {
|
|
100
|
+
return {
|
|
101
|
+
...(await this.#repository.getHealth()),
|
|
102
|
+
mode: this.#discord ? "live" : "dry_run",
|
|
103
|
+
discordWritesEnabled: Boolean(this.#discord),
|
|
104
|
+
workerRunning: Boolean(this.#timer) && !this.#stopping,
|
|
105
|
+
activeCount: this.#active.size,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
#runSoon() {
|
|
109
|
+
if (this.#claiming || this.#stopping)
|
|
110
|
+
return;
|
|
111
|
+
this.#claiming = this.runOnce()
|
|
112
|
+
.catch((error) => {
|
|
113
|
+
this.#logger.error({
|
|
114
|
+
event: "guild_commands.claim_failed",
|
|
115
|
+
errorName: error instanceof Error ? error.name : "Error",
|
|
116
|
+
}, "Guild command claim failed");
|
|
117
|
+
})
|
|
118
|
+
.finally(() => {
|
|
119
|
+
this.#claiming = undefined;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async #execute(request) {
|
|
123
|
+
if (this.#controller.signal.aborted)
|
|
124
|
+
return;
|
|
125
|
+
let leaseAlive = true;
|
|
126
|
+
const renew = () => {
|
|
127
|
+
if (!leaseAlive || this.#controller.signal.aborted)
|
|
128
|
+
return;
|
|
129
|
+
void this.#repository
|
|
130
|
+
.heartbeat({
|
|
131
|
+
requestId: request.requestId,
|
|
132
|
+
workerId: this.#workerId,
|
|
133
|
+
leaseGeneration: request.leaseGeneration,
|
|
134
|
+
fencingToken: this.#fencingToken,
|
|
135
|
+
leaseMs: this.#leaseMs,
|
|
136
|
+
})
|
|
137
|
+
.then((alive) => {
|
|
138
|
+
leaseAlive = alive;
|
|
139
|
+
})
|
|
140
|
+
.catch(() => {
|
|
141
|
+
leaseAlive = false;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const heartbeat = setInterval(renew, Math.max(1_000, Math.floor(this.#leaseMs / 3)));
|
|
145
|
+
heartbeat.unref();
|
|
146
|
+
try {
|
|
147
|
+
const [enabled, availability, state] = await Promise.all([
|
|
148
|
+
new InstallationRepository(this.#database).listEnabledModules(request.guildId),
|
|
149
|
+
new ModuleAvailabilityRepository(this.#database).list(),
|
|
150
|
+
this.#repository.getState(request.applicationId, request.guildId),
|
|
151
|
+
]);
|
|
152
|
+
if (!state || state.desiredRevision !== request.requestedRevision) {
|
|
153
|
+
await this.#repository.completeDryRun({
|
|
154
|
+
request,
|
|
155
|
+
workerId: this.#workerId,
|
|
156
|
+
fencingToken: this.#fencingToken,
|
|
157
|
+
definitionHash: "0".repeat(64),
|
|
158
|
+
rootCount: 0,
|
|
159
|
+
subcommandCount: 0,
|
|
160
|
+
createdCount: 0,
|
|
161
|
+
updatedCount: 0,
|
|
162
|
+
removedCount: 0,
|
|
163
|
+
noChange: false,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const projection = projectGuildCommands({
|
|
168
|
+
modules: this.#modules,
|
|
169
|
+
enabledModuleIds: new Set(enabled.map(({ moduleId }) => moduleId)),
|
|
170
|
+
globallyDisabledModuleIds: new Set(availability
|
|
171
|
+
.filter(({ globallyDisabled }) => globallyDisabled)
|
|
172
|
+
.map(({ moduleId }) => moduleId)),
|
|
173
|
+
profile: state.visibilityProfile,
|
|
174
|
+
includeInternalCommands: request.guildId === this.#internalGuildId,
|
|
175
|
+
});
|
|
176
|
+
if (this.#discord?.canWrite(request.guildId) &&
|
|
177
|
+
leaseAlive &&
|
|
178
|
+
!this.#controller.signal.aborted) {
|
|
179
|
+
await this.#executeLive(request, projection);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const diff = diffDryRunGuildCommands(projection, state.desiredDefinitionHash);
|
|
183
|
+
const outcome = await this.#completeDryRun(request, projection, diff);
|
|
184
|
+
const noChange = hasNoChanges(diff);
|
|
185
|
+
this.#logger.info({
|
|
186
|
+
event: noChange
|
|
187
|
+
? "guild_commands.dry_run_no_change"
|
|
188
|
+
: "guild_commands.dry_run_completed",
|
|
189
|
+
requestId: request.requestId,
|
|
190
|
+
guildId: request.guildId,
|
|
191
|
+
revision: request.requestedRevision.toString(),
|
|
192
|
+
rootCount: projection.rootCount,
|
|
193
|
+
subcommandCount: projection.subcommandCount,
|
|
194
|
+
predictedCreates: diff.create.length,
|
|
195
|
+
noChange,
|
|
196
|
+
outcome,
|
|
197
|
+
}, noChange
|
|
198
|
+
? "Guild command dry run found no changes"
|
|
199
|
+
: "Guild command dry run completed");
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (!this.#controller.signal.aborted && leaseAlive) {
|
|
203
|
+
const safeCode = error instanceof GuildCommandLiveError
|
|
204
|
+
? error.safeCode
|
|
205
|
+
: "projection_failed";
|
|
206
|
+
if (safeCode === "discord_observation_failed" ||
|
|
207
|
+
safeCode === "discord_rate_limited" ||
|
|
208
|
+
safeCode === "discord_write_ambiguous" ||
|
|
209
|
+
safeCode === "discord_write_mismatch")
|
|
210
|
+
await this.#repository.defer({
|
|
211
|
+
request,
|
|
212
|
+
workerId: this.#workerId,
|
|
213
|
+
fencingToken: this.#fencingToken,
|
|
214
|
+
delayMs: retryDelay(request.attemptCount),
|
|
215
|
+
safeCode,
|
|
216
|
+
});
|
|
217
|
+
else if (safeCode === "creation_budget_exhausted")
|
|
218
|
+
await this.#repository.markBlocked({
|
|
219
|
+
request,
|
|
220
|
+
workerId: this.#workerId,
|
|
221
|
+
fencingToken: this.#fencingToken,
|
|
222
|
+
safeCode,
|
|
223
|
+
});
|
|
224
|
+
else
|
|
225
|
+
await this.#repository.markFailed({
|
|
226
|
+
request,
|
|
227
|
+
workerId: this.#workerId,
|
|
228
|
+
fencingToken: this.#fencingToken,
|
|
229
|
+
safeCode,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
clearInterval(heartbeat);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async #completeDryRun(request, projection, diff) {
|
|
239
|
+
if (diff.conflicts.length > 0)
|
|
240
|
+
throw new Error("Guild command projection produced conflicts");
|
|
241
|
+
if (this.#controller.signal.aborted)
|
|
242
|
+
return "fenced";
|
|
243
|
+
return this.#repository.completeDryRun({
|
|
244
|
+
request,
|
|
245
|
+
workerId: this.#workerId,
|
|
246
|
+
fencingToken: this.#fencingToken,
|
|
247
|
+
definitionHash: projection.definitionHash,
|
|
248
|
+
rootCount: projection.rootCount,
|
|
249
|
+
subcommandCount: projection.subcommandCount,
|
|
250
|
+
createdCount: diff.create.length,
|
|
251
|
+
updatedCount: diff.update.length,
|
|
252
|
+
removedCount: diff.remove.length,
|
|
253
|
+
noChange: hasNoChanges(diff),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async #executeLive(request, projection) {
|
|
257
|
+
if (!this.#discord)
|
|
258
|
+
throw new GuildCommandLiveError("discord_write_disabled");
|
|
259
|
+
const result = await reconcileLiveGuildCommands({
|
|
260
|
+
repository: this.#repository,
|
|
261
|
+
discord: this.#discord,
|
|
262
|
+
request,
|
|
263
|
+
projection,
|
|
264
|
+
workerId: this.#workerId,
|
|
265
|
+
fencingToken: this.#fencingToken,
|
|
266
|
+
leaseMs: this.#leaseMs,
|
|
267
|
+
verificationDelayMs: 1_000,
|
|
268
|
+
signal: this.#controller.signal,
|
|
269
|
+
});
|
|
270
|
+
if (!result)
|
|
271
|
+
return;
|
|
272
|
+
this.#logger.info({
|
|
273
|
+
event: "guild_commands.live_reconciled",
|
|
274
|
+
requestId: request.requestId,
|
|
275
|
+
guildId: request.guildId,
|
|
276
|
+
revision: request.requestedRevision.toString(),
|
|
277
|
+
rootCount: projection.rootCount,
|
|
278
|
+
createdCount: result.diff.create.length,
|
|
279
|
+
updatedCount: result.diff.update.length,
|
|
280
|
+
removedCount: result.diff.remove.length,
|
|
281
|
+
outcome: result.outcome,
|
|
282
|
+
}, "Guild commands reconciled");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
export async function reconcileLiveGuildCommands(input) {
|
|
286
|
+
let observed;
|
|
287
|
+
try {
|
|
288
|
+
observed = await input.discord.observe(input.request.guildId);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
throw new GuildCommandLiveError(isDiscordRateLimit(error)
|
|
292
|
+
? "discord_rate_limited"
|
|
293
|
+
: "discord_observation_failed");
|
|
294
|
+
}
|
|
295
|
+
const diff = diffGuildCommands(input.projection, observed);
|
|
296
|
+
if (diff.conflicts.length > 0)
|
|
297
|
+
throw new GuildCommandLiveError("discord_command_conflict");
|
|
298
|
+
let ledgerId;
|
|
299
|
+
if (diff.create.length > 0) {
|
|
300
|
+
try {
|
|
301
|
+
ledgerId = (await input.repository.reserveCreations({
|
|
302
|
+
applicationId: input.request.applicationId,
|
|
303
|
+
guildId: input.request.guildId,
|
|
304
|
+
requestId: input.request.requestId,
|
|
305
|
+
correlationId: input.request.correlationId,
|
|
306
|
+
bucket: input.request.budgetClass,
|
|
307
|
+
commandCount: diff.create.length,
|
|
308
|
+
reservationMs: Math.min(300_000, Math.max(60_000, input.leaseMs * 2)),
|
|
309
|
+
reasonCode: "guild_command_create",
|
|
310
|
+
})).ledgerId;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error instanceof GuildCommandBudgetExceededError)
|
|
314
|
+
throw new GuildCommandLiveError("creation_budget_exhausted");
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const current = await input.repository.isLeaseCurrent({
|
|
319
|
+
request: input.request,
|
|
320
|
+
workerId: input.workerId,
|
|
321
|
+
fencingToken: input.fencingToken,
|
|
322
|
+
});
|
|
323
|
+
if (!current || input.signal?.aborted) {
|
|
324
|
+
if (ledgerId)
|
|
325
|
+
await input.repository.settleCreation({
|
|
326
|
+
ledgerId,
|
|
327
|
+
state: "released",
|
|
328
|
+
observedReference: "write_not_started",
|
|
329
|
+
});
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (!hasNoChanges(diff)) {
|
|
333
|
+
let written;
|
|
334
|
+
try {
|
|
335
|
+
written = await input.discord.replace(input.request.guildId, input.projection.definitions);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
try {
|
|
339
|
+
written = await input.discord.observe(input.request.guildId);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
if (ledgerId)
|
|
343
|
+
await input.repository.settleCreation({
|
|
344
|
+
ledgerId,
|
|
345
|
+
state: "committed",
|
|
346
|
+
observedReference: `discord:guild:${input.request.guildId}:write_ambiguous`,
|
|
347
|
+
});
|
|
348
|
+
throw new GuildCommandLiveError("discord_write_ambiguous");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
let verifiedDiff = diffGuildCommands(input.projection, written);
|
|
352
|
+
if (!hasNoChanges(verifiedDiff)) {
|
|
353
|
+
try {
|
|
354
|
+
await waitForPropagation(input.verificationDelayMs ?? 0, input.signal);
|
|
355
|
+
written = await input.discord.observe(input.request.guildId);
|
|
356
|
+
verifiedDiff = diffGuildCommands(input.projection, written);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
if (ledgerId)
|
|
360
|
+
await input.repository.settleCreation({
|
|
361
|
+
ledgerId,
|
|
362
|
+
state: "committed",
|
|
363
|
+
observedReference: `discord:guild:${input.request.guildId}:verification_ambiguous`,
|
|
364
|
+
});
|
|
365
|
+
throw new GuildCommandLiveError("discord_write_ambiguous");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!hasNoChanges(verifiedDiff)) {
|
|
369
|
+
if (ledgerId)
|
|
370
|
+
await input.repository.settleCreation({
|
|
371
|
+
ledgerId,
|
|
372
|
+
state: "committed",
|
|
373
|
+
observedReference: `discord:guild:${input.request.guildId}:roots:${written.length}:verification_mismatch`,
|
|
374
|
+
});
|
|
375
|
+
throw new GuildCommandLiveError("discord_write_mismatch");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const outcome = await input.repository.completeLive({
|
|
379
|
+
request: input.request,
|
|
380
|
+
workerId: input.workerId,
|
|
381
|
+
fencingToken: input.fencingToken,
|
|
382
|
+
definitionHash: input.projection.definitionHash,
|
|
383
|
+
rootCount: input.projection.rootCount,
|
|
384
|
+
subcommandCount: input.projection.subcommandCount,
|
|
385
|
+
observedDefinitionHash: input.projection.definitionHash,
|
|
386
|
+
installedRootCount: input.projection.rootCount,
|
|
387
|
+
createdCount: diff.create.length,
|
|
388
|
+
updatedCount: diff.update.length,
|
|
389
|
+
removedCount: diff.remove.length,
|
|
390
|
+
createdCommands: diff.create.map(({ name }) => `/${name}`),
|
|
391
|
+
updatedCommands: diff.update.map(({ name }) => `/${name}`),
|
|
392
|
+
removedCommands: diff.remove.map(({ name }) => `/${name}`),
|
|
393
|
+
...(ledgerId ? { ledgerId } : {}),
|
|
394
|
+
observedReference: `discord:guild:${input.request.guildId}:roots:${input.projection.rootCount}`,
|
|
395
|
+
});
|
|
396
|
+
return { outcome, diff };
|
|
397
|
+
}
|
|
398
|
+
class GuildCommandLiveError extends Error {
|
|
399
|
+
safeCode;
|
|
400
|
+
constructor(safeCode) {
|
|
401
|
+
super(safeCode);
|
|
402
|
+
this.safeCode = safeCode;
|
|
403
|
+
this.name = "GuildCommandLiveError";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function hasNoChanges(diff) {
|
|
407
|
+
return (!diff.observationRequired &&
|
|
408
|
+
diff.create.length === 0 &&
|
|
409
|
+
diff.update.length === 0 &&
|
|
410
|
+
diff.remove.length === 0 &&
|
|
411
|
+
diff.conflicts.length === 0);
|
|
412
|
+
}
|
|
413
|
+
function retryDelay(attemptCount) {
|
|
414
|
+
return Math.min(60_000, 2_000 * 2 ** Math.max(0, attemptCount - 1));
|
|
415
|
+
}
|
|
416
|
+
function isDiscordRateLimit(error) {
|
|
417
|
+
if (!error || typeof error !== "object")
|
|
418
|
+
return false;
|
|
419
|
+
const candidate = error;
|
|
420
|
+
return candidate.status === 429 || candidate.statusCode === 429;
|
|
421
|
+
}
|
|
422
|
+
function boundedInteger(value, minimum, maximum, label) {
|
|
423
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
424
|
+
throw new Error(`${label} must be between ${minimum} and ${maximum}`);
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
function wait(milliseconds) {
|
|
428
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
429
|
+
}
|
|
430
|
+
function waitForPropagation(milliseconds, signal) {
|
|
431
|
+
if (milliseconds <= 0 || signal?.aborted)
|
|
432
|
+
return Promise.resolve();
|
|
433
|
+
return new Promise((resolve) => {
|
|
434
|
+
const timeout = setTimeout(finish, milliseconds);
|
|
435
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
436
|
+
function finish() {
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
signal?.removeEventListener("abort", finish);
|
|
439
|
+
resolve();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
//# sourceMappingURL=guild-command-worker.js.map
|
package/dist/guilds.d.ts
CHANGED
|
@@ -21,5 +21,5 @@ export interface GuildTrackingPreparation {
|
|
|
21
21
|
health(): GuildReconciliationHealth;
|
|
22
22
|
stop(): void;
|
|
23
23
|
}
|
|
24
|
-
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number): GuildTrackingPreparation;
|
|
24
|
+
export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string): GuildTrackingPreparation;
|
|
25
25
|
//# sourceMappingURL=guilds.d.ts.map
|
package/dist/guilds.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { InstallationRepository, } from "@helyx/database";
|
|
1
|
+
import { InstallationRepository, GuildCommandRepository, withTransaction, } from "@helyx/database";
|
|
2
2
|
import { Events } from "discord.js";
|
|
3
3
|
const RECONCILIATION_PAGE_SIZE = 250;
|
|
4
4
|
const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
|
|
5
|
-
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount) {
|
|
5
|
+
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId) {
|
|
6
6
|
if (!Number.isSafeInteger(reconciliationIntervalMs) ||
|
|
7
7
|
reconciliationIntervalMs < 60_000)
|
|
8
8
|
throw new Error("Guild reconciliation interval must be at least one minute");
|
|
@@ -19,33 +19,46 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
19
19
|
rejectedFences: 0,
|
|
20
20
|
unavailableGuilds: 0,
|
|
21
21
|
};
|
|
22
|
-
async function syncGuild(guild) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
name: guild.name,
|
|
27
|
-
...(ownership ? { ownership } : {}),
|
|
28
|
-
});
|
|
29
|
-
if (ownership &&
|
|
30
|
-
(record.shardGeneration !== ownership.generation ||
|
|
31
|
-
record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
|
|
32
|
-
record.shardSupervisorId !== ownership.supervisorId ||
|
|
33
|
-
record.shardId !== ownership.shardId ||
|
|
34
|
-
record.shardFencingToken !== ownership.fencingToken))
|
|
35
|
-
throw new Error("Guild observation was rejected by a newer shard owner");
|
|
36
|
-
for (const module of modules) {
|
|
37
|
-
await repository.ensureModule({
|
|
22
|
+
async function syncGuild(guild, firstObservation = false) {
|
|
23
|
+
const persist = async (connection) => {
|
|
24
|
+
const installations = new InstallationRepository(connection);
|
|
25
|
+
const record = await installations.syncGuild({
|
|
38
26
|
guildId: guild.id,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
enabledBy: guild.ownerId,
|
|
43
|
-
enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
|
|
27
|
+
ownerUserId: guild.ownerId,
|
|
28
|
+
name: guild.name,
|
|
29
|
+
...(ownership ? { ownership } : {}),
|
|
44
30
|
});
|
|
45
|
-
|
|
31
|
+
if (ownership &&
|
|
32
|
+
(record.shardGeneration !== ownership.generation ||
|
|
33
|
+
record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
|
|
34
|
+
record.shardSupervisorId !== ownership.supervisorId ||
|
|
35
|
+
record.shardId !== ownership.shardId ||
|
|
36
|
+
record.shardFencingToken !== ownership.fencingToken))
|
|
37
|
+
throw new Error("Guild observation was rejected by a newer shard owner");
|
|
38
|
+
if (firstObservation && applicationId)
|
|
39
|
+
await new GuildCommandRepository(connection).ensureState({
|
|
40
|
+
applicationId,
|
|
41
|
+
guildId: guild.id,
|
|
42
|
+
setupState: "incomplete",
|
|
43
|
+
});
|
|
44
|
+
for (const module of modules) {
|
|
45
|
+
await installations.ensureModule({
|
|
46
|
+
guildId: guild.id,
|
|
47
|
+
moduleId: module.manifest.id,
|
|
48
|
+
moduleVersion: module.manifest.version,
|
|
49
|
+
configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
|
|
50
|
+
enabledBy: guild.ownerId,
|
|
51
|
+
enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
if (firstObservation)
|
|
56
|
+
await withTransaction(database, persist);
|
|
57
|
+
else
|
|
58
|
+
await persist(database);
|
|
46
59
|
}
|
|
47
60
|
client.on(Events.GuildCreate, (guild) => {
|
|
48
|
-
void syncGuild(guild).catch((error) => {
|
|
61
|
+
void syncGuild(guild, true).catch((error) => {
|
|
49
62
|
logger.error({
|
|
50
63
|
event: "guild.sync_failed",
|
|
51
64
|
guildId: guild.id,
|
|
@@ -56,7 +69,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
56
69
|
client.on(Events.GuildDelete, (guild) => {
|
|
57
70
|
void repository
|
|
58
71
|
.markGuildRemoved(guild.id, ownership)
|
|
59
|
-
.then((changed) => {
|
|
72
|
+
.then(async (changed) => {
|
|
73
|
+
if (changed && applicationId)
|
|
74
|
+
await new GuildCommandRepository(database).cancelGuildWork(applicationId, guild.id);
|
|
60
75
|
logger.info({
|
|
61
76
|
event: "guild.removed",
|
|
62
77
|
outcome: changed ? "changed" : "rejected_or_duplicate",
|