@helyx/module-reactionroles 1.0.15 → 1.0.17
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 +13 -0
- package/README.md +37 -53
- package/dist/builder-interactions.d.ts +11 -0
- package/dist/builder-interactions.js +293 -0
- package/dist/builder-presentation.d.ts +30 -0
- package/dist/builder-presentation.js +343 -0
- package/dist/builder.d.ts +3 -0
- package/dist/builder.js +22 -16
- package/dist/cleanup-repository.d.ts +24 -0
- package/dist/cleanup-repository.js +118 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +4 -1
- package/dist/health.d.ts +16 -0
- package/dist/health.js +85 -0
- package/dist/index.js +66 -1147
- package/dist/logging.d.ts +33 -0
- package/dist/logging.js +40 -0
- package/dist/managed-resource.d.ts +2 -0
- package/dist/managed-resource.js +72 -0
- package/dist/member-interactions.d.ts +27 -0
- package/dist/member-interactions.js +360 -0
- package/dist/module-lifecycle.d.ts +3 -0
- package/dist/module-lifecycle.js +48 -0
- package/dist/panel-cache.d.ts +30 -0
- package/dist/panel-cache.js +197 -0
- package/dist/panel-create.d.ts +10 -0
- package/dist/panel-create.js +80 -0
- package/dist/panel-lifecycle.d.ts +47 -0
- package/dist/panel-lifecycle.js +272 -0
- package/dist/panel-managed.d.ts +14 -0
- package/dist/panel-managed.js +27 -0
- package/dist/panel-service.d.ts +13 -36
- package/dist/panel-service.js +48 -722
- package/dist/panel-support.d.ts +35 -0
- package/dist/panel-support.js +291 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +8 -0
- package/dist/privacy.d.ts +5 -0
- package/dist/privacy.js +12 -0
- package/dist/record-mapping.d.ts +28 -0
- package/dist/record-mapping.js +161 -0
- package/dist/record-schema.d.ts +203 -0
- package/dist/record-schema.js +137 -0
- package/dist/records.d.ts +9 -201
- package/dist/records.js +32 -383
- package/dist/runtime-state.d.ts +9 -0
- package/dist/runtime-state.js +30 -0
- package/manifest.json +1 -1
- package/package.json +2 -2
package/dist/panel-service.js
CHANGED
|
@@ -1,373 +1,58 @@
|
|
|
1
1
|
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { validatePanelDraft } from "./builder.js";
|
|
3
|
+
import { createPanel } from "./panel-create.js";
|
|
4
|
+
import { PanelLifecycleService } from "./panel-lifecycle.js";
|
|
5
|
+
import { ManagedPanelReader } from "./panel-managed.js";
|
|
5
6
|
import { ReactionRolePanelRepository } from "./records.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const MAX_CONCURRENT_CACHE_LOADS = 1_000;
|
|
10
|
-
export class PanelSnapshotCache {
|
|
11
|
-
#panels = new Map();
|
|
12
|
-
#negative = new Map();
|
|
13
|
-
#loads = new Map();
|
|
14
|
-
#epochs = new Map();
|
|
15
|
-
#suspensions = new Map();
|
|
16
|
-
#deferred = new Map();
|
|
17
|
-
#saturated = false;
|
|
18
|
-
get(guildId, messageId) {
|
|
19
|
-
const key = cacheKey(guildId, messageId);
|
|
20
|
-
if (this.#suspensions.has(key))
|
|
21
|
-
return null;
|
|
22
|
-
return this.#panels.get(key)?.panel ?? null;
|
|
23
|
-
}
|
|
24
|
-
getFresh(guildId, messageId) {
|
|
25
|
-
const key = cacheKey(guildId, messageId);
|
|
26
|
-
if (this.#suspensions.has(key))
|
|
27
|
-
return null;
|
|
28
|
-
const cached = this.#panels.get(key);
|
|
29
|
-
return cached && Date.now() - cached.cachedAt <= ACTIVE_CACHE_TTL_MS
|
|
30
|
-
? cached.panel
|
|
31
|
-
: null;
|
|
32
|
-
}
|
|
33
|
-
negativeHit(guildId, messageId) {
|
|
34
|
-
const key = cacheKey(guildId, messageId);
|
|
35
|
-
if (this.#suspensions.has(key))
|
|
36
|
-
return false;
|
|
37
|
-
const expiresAt = this.#negative.get(key);
|
|
38
|
-
if (!expiresAt)
|
|
39
|
-
return false;
|
|
40
|
-
if (expiresAt > Date.now())
|
|
41
|
-
return true;
|
|
42
|
-
this.#negative.delete(key);
|
|
43
|
-
this.#pruneEpoch(key);
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
markMissing(guildId, messageId) {
|
|
47
|
-
const key = cacheKey(guildId, messageId);
|
|
48
|
-
if (this.#negative.size >= MAX_CONCURRENT_CACHE_LOADS) {
|
|
49
|
-
const oldest = this.#negative.keys().next().value;
|
|
50
|
-
this.#negative.delete(oldest);
|
|
51
|
-
this.#pruneEpoch(oldest);
|
|
52
|
-
}
|
|
53
|
-
this.#negative.set(key, Date.now() + NEGATIVE_CACHE_TTL_MS);
|
|
54
|
-
this.#panels.delete(key);
|
|
55
|
-
}
|
|
56
|
-
load(guildId, messageId, operation) {
|
|
57
|
-
const key = cacheKey(guildId, messageId);
|
|
58
|
-
if (this.#suspensions.has(key))
|
|
59
|
-
return Promise.resolve(null);
|
|
60
|
-
const existing = this.#loads.get(key);
|
|
61
|
-
if (existing)
|
|
62
|
-
return existing;
|
|
63
|
-
if (this.#loads.size >= MAX_CONCURRENT_CACHE_LOADS)
|
|
64
|
-
return Promise.resolve(null);
|
|
65
|
-
const epoch = this.#epoch(key);
|
|
66
|
-
const pending = operation()
|
|
67
|
-
.then((panel) => {
|
|
68
|
-
if (this.#epochs.get(key) !== epoch || this.#suspensions.has(key))
|
|
69
|
-
return null;
|
|
70
|
-
if (panel)
|
|
71
|
-
this.put(panel);
|
|
72
|
-
else
|
|
73
|
-
this.markMissing(guildId, messageId);
|
|
74
|
-
return panel;
|
|
75
|
-
})
|
|
76
|
-
.finally(() => {
|
|
77
|
-
this.#loads.delete(key);
|
|
78
|
-
this.#pruneEpoch(key);
|
|
79
|
-
});
|
|
80
|
-
this.#loads.set(key, pending);
|
|
81
|
-
return pending;
|
|
82
|
-
}
|
|
83
|
-
put(panel) {
|
|
84
|
-
if (!panel.messageId ||
|
|
85
|
-
(panel.status !== "active" && panel.status !== "cleanup_pending")) {
|
|
86
|
-
this.remove(panel);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const key = cacheKey(panel.guildId, panel.messageId);
|
|
90
|
-
if (this.#suspensions.has(key)) {
|
|
91
|
-
const deferred = this.#deferred.get(key);
|
|
92
|
-
if (!deferred || deferred.revision <= panel.revision)
|
|
93
|
-
this.#deferred.set(key, panel);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const cached = this.#panels.get(key);
|
|
97
|
-
if (cached && cached.panel.revision > panel.revision)
|
|
98
|
-
return;
|
|
99
|
-
if (this.#panels.size >= MAX_ACTIVE_CACHE && !this.#panels.has(key)) {
|
|
100
|
-
this.#saturated = true;
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
this.#panels.set(key, { panel, cachedAt: Date.now() });
|
|
104
|
-
this.#negative.delete(key);
|
|
105
|
-
}
|
|
106
|
-
remove(panel) {
|
|
107
|
-
if (!panel.messageId)
|
|
108
|
-
return;
|
|
109
|
-
const key = cacheKey(panel.guildId, panel.messageId);
|
|
110
|
-
this.#invalidate(key);
|
|
111
|
-
this.#deferred.delete(key);
|
|
112
|
-
}
|
|
113
|
-
suspend(panel) {
|
|
114
|
-
if (!panel.messageId) {
|
|
115
|
-
let resumed = false;
|
|
116
|
-
return (replacement) => {
|
|
117
|
-
if (resumed)
|
|
118
|
-
return;
|
|
119
|
-
resumed = true;
|
|
120
|
-
if (replacement)
|
|
121
|
-
this.put(replacement);
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
const key = cacheKey(panel.guildId, panel.messageId);
|
|
125
|
-
const token = {};
|
|
126
|
-
const suspensions = this.#suspensions.get(key) ?? new Set();
|
|
127
|
-
suspensions.add(token);
|
|
128
|
-
this.#suspensions.set(key, suspensions);
|
|
129
|
-
this.#invalidate(key);
|
|
130
|
-
let resumed = false;
|
|
131
|
-
return (replacement) => {
|
|
132
|
-
if (resumed)
|
|
133
|
-
return;
|
|
134
|
-
resumed = true;
|
|
135
|
-
const current = this.#suspensions.get(key);
|
|
136
|
-
current?.delete(token);
|
|
137
|
-
if (current?.size === 0) {
|
|
138
|
-
this.#suspensions.delete(key);
|
|
139
|
-
const deferred = this.#deferred.get(key);
|
|
140
|
-
this.#deferred.delete(key);
|
|
141
|
-
if (deferred)
|
|
142
|
-
this.put(deferred);
|
|
143
|
-
}
|
|
144
|
-
if (replacement)
|
|
145
|
-
this.put(replacement);
|
|
146
|
-
this.#pruneEpoch(key);
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
clear() {
|
|
150
|
-
this.#panels.clear();
|
|
151
|
-
this.#negative.clear();
|
|
152
|
-
this.#loads.clear();
|
|
153
|
-
this.#epochs.clear();
|
|
154
|
-
this.#suspensions.clear();
|
|
155
|
-
this.#deferred.clear();
|
|
156
|
-
this.#saturated = false;
|
|
157
|
-
}
|
|
158
|
-
size() {
|
|
159
|
-
return this.#panels.size;
|
|
160
|
-
}
|
|
161
|
-
saturated() {
|
|
162
|
-
return this.#saturated;
|
|
163
|
-
}
|
|
164
|
-
#epoch(key) {
|
|
165
|
-
const epoch = this.#epochs.get(key) ?? {};
|
|
166
|
-
this.#epochs.set(key, epoch);
|
|
167
|
-
return epoch;
|
|
168
|
-
}
|
|
169
|
-
#invalidate(key) {
|
|
170
|
-
if (this.#loads.has(key) || this.#suspensions.has(key))
|
|
171
|
-
this.#epochs.set(key, {});
|
|
172
|
-
else
|
|
173
|
-
this.#epochs.delete(key);
|
|
174
|
-
this.#panels.delete(key);
|
|
175
|
-
this.#negative.delete(key);
|
|
176
|
-
}
|
|
177
|
-
#pruneEpoch(key) {
|
|
178
|
-
if (!this.#loads.has(key) &&
|
|
179
|
-
!this.#panels.has(key) &&
|
|
180
|
-
!this.#negative.has(key) &&
|
|
181
|
-
!this.#suspensions.has(key))
|
|
182
|
-
this.#epochs.delete(key);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
7
|
+
import { appendPanelAudit, buildPanel, emitLog, prepareEntries, requiresReplacement, seedReactions, validateDiscordResources, } from "./panel-support.js";
|
|
8
|
+
export { managedContext, parseManagedDraft, toDetail, } from "./panel-support.js";
|
|
9
|
+
export { PanelSnapshotCache } from "./panel-cache.js";
|
|
185
10
|
export class ReactionRolePanelService {
|
|
186
11
|
cache;
|
|
12
|
+
#lifecycle;
|
|
13
|
+
#managed;
|
|
187
14
|
constructor(cache) {
|
|
188
15
|
this.cache = cache;
|
|
16
|
+
this.#lifecycle = new PanelLifecycleService(cache, (services) => this.repository(services));
|
|
17
|
+
this.#managed = new ManagedPanelReader((services) => this.repository(services));
|
|
189
18
|
}
|
|
190
19
|
repository(services) {
|
|
191
20
|
return new ReactionRolePanelRepository(services.get(HELYX_SERVICE_NAMES.records));
|
|
192
21
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.cache.clear();
|
|
196
|
-
for (const status of ["active", "cleanup_pending"]) {
|
|
197
|
-
let cursor;
|
|
198
|
-
do {
|
|
199
|
-
const page = await repository.listInteractive({
|
|
200
|
-
status,
|
|
201
|
-
...(cursor ? { cursor } : {}),
|
|
202
|
-
limit: 100,
|
|
203
|
-
});
|
|
204
|
-
for (const panel of page.panels)
|
|
205
|
-
this.cache.put(panel);
|
|
206
|
-
cursor = page.nextCursor ?? undefined;
|
|
207
|
-
} while (cursor && !this.cache.saturated());
|
|
208
|
-
if (this.cache.saturated())
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
return this.cache.size();
|
|
22
|
+
warm(services) {
|
|
23
|
+
return this.#lifecycle.warm(services);
|
|
212
24
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
let abandoned = 0;
|
|
222
|
-
for (const cleanup of pending) {
|
|
223
|
-
if (cleanup.attempts >= 10) {
|
|
224
|
-
abandoned += 1;
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
const claimed = await repository.claimCleanup(cleanup);
|
|
228
|
-
if (!claimed)
|
|
229
|
-
continue;
|
|
230
|
-
try {
|
|
231
|
-
await messages.deleteOwnedMessage({
|
|
232
|
-
guildId: claimed.guildId,
|
|
233
|
-
channelId: claimed.channelId,
|
|
234
|
-
messageId: claimed.messageId,
|
|
235
|
-
});
|
|
236
|
-
if (!(await repository.completeCleanup(claimed))) {
|
|
237
|
-
failed += 1;
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
completed += 1;
|
|
241
|
-
if ((await repository.pendingCleanupCount(claimed.panelId)) === 0) {
|
|
242
|
-
const panel = await repository.findById(claimed.guildId, claimed.panelId);
|
|
243
|
-
if (panel?.status === "cleanup_pending")
|
|
244
|
-
await repository.markStatus({
|
|
245
|
-
panelId: panel.panelId,
|
|
246
|
-
expectedRevision: panel.revision,
|
|
247
|
-
status: "active",
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
failed += 1;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return { completed, failed, abandoned };
|
|
25
|
+
recoverMessageCleanup(services) {
|
|
26
|
+
return this.#lifecycle.recoverMessageCleanup(services);
|
|
27
|
+
}
|
|
28
|
+
executeMessageCleanup(services, cleanupId) {
|
|
29
|
+
return this.#lifecycle.executeMessageCleanup(services, cleanupId);
|
|
30
|
+
}
|
|
31
|
+
executeMessageCleanupWithRecovery(services, cleanupId) {
|
|
32
|
+
return this.#lifecycle.executeMessageCleanupWithRecovery(services, cleanupId);
|
|
256
33
|
}
|
|
257
34
|
getCached(guildId, messageId) {
|
|
258
35
|
return this.cache.get(guildId, messageId);
|
|
259
36
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (fresh)
|
|
263
|
-
return fresh;
|
|
264
|
-
if (this.cache.negativeHit(guildId, messageId))
|
|
265
|
-
return null;
|
|
266
|
-
return this.cache.load(guildId, messageId, async () => {
|
|
267
|
-
const panel = await this.repository(services).findByMessage(guildId, messageId);
|
|
268
|
-
if (panel &&
|
|
269
|
-
(panel.status === "active" || panel.status === "cleanup_pending"))
|
|
270
|
-
return panel;
|
|
271
|
-
return null;
|
|
272
|
-
});
|
|
37
|
+
resolveActive(guildId, messageId, services) {
|
|
38
|
+
return this.#lifecycle.resolveActive(guildId, messageId, services);
|
|
273
39
|
}
|
|
274
40
|
async list(context, input) {
|
|
275
|
-
|
|
276
|
-
guildId: context.guildId,
|
|
277
|
-
...(input.cursor ? { cursor: decodeCursor(input.cursor) } : {}),
|
|
278
|
-
limit: Math.min(100, Math.max(1, input.limit)),
|
|
279
|
-
});
|
|
280
|
-
return {
|
|
281
|
-
items: page.panels.map(toSummary),
|
|
282
|
-
...(page.nextCursor ? { nextCursor: encodeCursor(page.nextCursor) } : {}),
|
|
283
|
-
};
|
|
41
|
+
return this.#managed.list(context, input);
|
|
284
42
|
}
|
|
285
43
|
async read(context, resourceId) {
|
|
286
|
-
|
|
287
|
-
return panel ? toDetail(panel) : null;
|
|
44
|
+
return this.#managed.read(context, resourceId);
|
|
288
45
|
}
|
|
289
46
|
async preview(context, value) {
|
|
290
|
-
|
|
291
|
-
await validateDiscordResources(context.services, context.guildId, context.actor.userId, draft);
|
|
292
|
-
return buildPanel(context.services, draft).response;
|
|
47
|
+
return this.#managed.preview(context, value);
|
|
293
48
|
}
|
|
294
49
|
async create(context, draftInput) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const staged = await repository.create({
|
|
301
|
-
panelId,
|
|
302
|
-
guildId: context.guildId,
|
|
303
|
-
actorUserId: context.actorUserId,
|
|
304
|
-
draft,
|
|
305
|
-
entries,
|
|
50
|
+
return createPanel({
|
|
51
|
+
cache: this.cache,
|
|
52
|
+
repository: this.repository(context.services),
|
|
53
|
+
context,
|
|
54
|
+
draftInput,
|
|
306
55
|
});
|
|
307
|
-
if (!staged)
|
|
308
|
-
throw new DashboardActionValidationError("A role panel with this identifier already exists");
|
|
309
|
-
let messageId;
|
|
310
|
-
let panel;
|
|
311
|
-
try {
|
|
312
|
-
const message = await context.sendToChannel(draft.channelId, buildPanel(context.services, draft).response);
|
|
313
|
-
messageId = message.messageId;
|
|
314
|
-
await seedReactions(context, draft, message.messageId);
|
|
315
|
-
const pending = await repository.findById(context.guildId, panelId);
|
|
316
|
-
if (!pending)
|
|
317
|
-
throw new Error("Staged role panel could not be reloaded");
|
|
318
|
-
const activated = await repository.update({
|
|
319
|
-
panel: pending,
|
|
320
|
-
actorUserId: context.actorUserId,
|
|
321
|
-
draft,
|
|
322
|
-
messageId: message.messageId,
|
|
323
|
-
status: "active",
|
|
324
|
-
entries,
|
|
325
|
-
});
|
|
326
|
-
if (!activated)
|
|
327
|
-
throw new Error("Role panel activation conflicted");
|
|
328
|
-
const published = await repository.findById(context.guildId, panelId);
|
|
329
|
-
if (!published)
|
|
330
|
-
throw new Error("Published role panel could not be reloaded");
|
|
331
|
-
panel = published;
|
|
332
|
-
}
|
|
333
|
-
catch (error) {
|
|
334
|
-
let cleanupPending = false;
|
|
335
|
-
if (messageId) {
|
|
336
|
-
try {
|
|
337
|
-
await context.deleteChannelMessage(draft.channelId, messageId);
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
cleanupPending = await repository
|
|
341
|
-
.recordCleanup({
|
|
342
|
-
guildId: context.guildId,
|
|
343
|
-
panelId,
|
|
344
|
-
channelId: draft.channelId,
|
|
345
|
-
messageId,
|
|
346
|
-
reason: "rollback",
|
|
347
|
-
})
|
|
348
|
-
.catch(() => false);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
if (cleanupPending) {
|
|
352
|
-
const failed = await repository
|
|
353
|
-
.findById(context.guildId, panelId)
|
|
354
|
-
.catch(() => null);
|
|
355
|
-
if (failed)
|
|
356
|
-
await this.audit(context, "reactionroles.panel.publication.failed", failed, "cleanup_pending").catch(() => undefined);
|
|
357
|
-
}
|
|
358
|
-
await repository
|
|
359
|
-
.remove(panelId, 1, context.actorUserId)
|
|
360
|
-
.catch(() => false);
|
|
361
|
-
throw error;
|
|
362
|
-
}
|
|
363
|
-
this.cache.put(panel);
|
|
364
|
-
await this.audit(context, "reactionroles.panel.created", panel);
|
|
365
|
-
await emitLog(context.services, context.guildId, {
|
|
366
|
-
eventId: "reaction-role-panel-created",
|
|
367
|
-
summary: "A Reaction Roles panel was created.",
|
|
368
|
-
panelId,
|
|
369
|
-
});
|
|
370
|
-
return panel;
|
|
371
56
|
}
|
|
372
57
|
async update(context, input) {
|
|
373
58
|
const repository = this.repository(context.services);
|
|
@@ -480,14 +165,14 @@ export class ReactionRolePanelService {
|
|
|
480
165
|
resumeCache(safeFallback);
|
|
481
166
|
throw error;
|
|
482
167
|
}
|
|
483
|
-
await
|
|
168
|
+
await appendPanelAudit(context, "reactionroles.panel.updated", saved, cleanupPending ? "cleanup_pending" : "success");
|
|
484
169
|
await emitLog(context.services, context.guildId, {
|
|
485
170
|
eventId: "reaction-role-panel-updated",
|
|
486
171
|
summary: "A Reaction Roles panel was updated.",
|
|
487
172
|
panelId: saved.panelId,
|
|
488
173
|
});
|
|
489
174
|
if (input.replacementAction === "repair") {
|
|
490
|
-
await
|
|
175
|
+
await appendPanelAudit(context, "reactionroles.panel.repaired", saved);
|
|
491
176
|
await emitLog(context.services, context.guildId, {
|
|
492
177
|
eventId: "reaction-role-panel-repaired",
|
|
493
178
|
summary: "A Reaction Roles panel was repaired.",
|
|
@@ -495,7 +180,7 @@ export class ReactionRolePanelService {
|
|
|
495
180
|
});
|
|
496
181
|
}
|
|
497
182
|
else if (replace) {
|
|
498
|
-
await
|
|
183
|
+
await appendPanelAudit(context, "reactionroles.panel.reposted", saved);
|
|
499
184
|
await emitLog(context.services, context.guildId, {
|
|
500
185
|
eventId: "reaction-role-panel-reposted",
|
|
501
186
|
summary: "A Reaction Roles panel was reposted.",
|
|
@@ -503,7 +188,7 @@ export class ReactionRolePanelService {
|
|
|
503
188
|
});
|
|
504
189
|
}
|
|
505
190
|
if (cleanupPending)
|
|
506
|
-
await
|
|
191
|
+
await appendPanelAudit(context, "reactionroles.panel.cleanup.failed", saved, "cleanup_pending");
|
|
507
192
|
return saved;
|
|
508
193
|
}
|
|
509
194
|
async delete(context, panelId, expectedRevision) {
|
|
@@ -534,9 +219,9 @@ export class ReactionRolePanelService {
|
|
|
534
219
|
}
|
|
535
220
|
if (!(await repository.deleteClaimed(panel.panelId)))
|
|
536
221
|
throw new Error("The claimed role panel could not be deleted");
|
|
537
|
-
await
|
|
222
|
+
await appendPanelAudit(context, "reactionroles.panel.deleted", panel, cleanupPending ? "cleanup_pending" : "success");
|
|
538
223
|
if (cleanupPending)
|
|
539
|
-
await
|
|
224
|
+
await appendPanelAudit(context, "reactionroles.panel.cleanup.failed", panel, "cleanup_pending");
|
|
540
225
|
await emitLog(context.services, context.guildId, {
|
|
541
226
|
eventId: "reaction-role-panel-deleted",
|
|
542
227
|
summary: "A Reaction Roles panel was deleted.",
|
|
@@ -576,7 +261,7 @@ export class ReactionRolePanelService {
|
|
|
576
261
|
if (!saved)
|
|
577
262
|
throw new Error("Repaired role panel could not be reloaded");
|
|
578
263
|
this.cache.put(saved);
|
|
579
|
-
await
|
|
264
|
+
await appendPanelAudit(context, "reactionroles.panel.repaired", saved);
|
|
580
265
|
await emitLog(context.services, context.guildId, {
|
|
581
266
|
eventId: "reaction-role-panel-repaired",
|
|
582
267
|
summary: "A Reaction Roles panel was repaired.",
|
|
@@ -584,378 +269,19 @@ export class ReactionRolePanelService {
|
|
|
584
269
|
});
|
|
585
270
|
return saved;
|
|
586
271
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const panel = this.cache.get(input.guildId, input.messageId) ??
|
|
590
|
-
(await repository.findByMessage(input.guildId, input.messageId));
|
|
591
|
-
if (!panel)
|
|
592
|
-
return;
|
|
593
|
-
if (panel.status === "deleting")
|
|
594
|
-
return;
|
|
595
|
-
const changed = await repository.markStatus({
|
|
596
|
-
panelId: panel.panelId,
|
|
597
|
-
expectedRevision: panel.revision,
|
|
598
|
-
status: "message_missing",
|
|
599
|
-
});
|
|
600
|
-
if (changed)
|
|
601
|
-
this.cache.remove(panel);
|
|
602
|
-
}
|
|
603
|
-
async markNeedsAttention(panel, services) {
|
|
604
|
-
if (panel.status === "deleting" ||
|
|
605
|
-
panel.status === "message_missing" ||
|
|
606
|
-
panel.status === "needs_attention")
|
|
607
|
-
return false;
|
|
608
|
-
const changed = await this.repository(services).markStatus({
|
|
609
|
-
panelId: panel.panelId,
|
|
610
|
-
expectedRevision: panel.revision,
|
|
611
|
-
status: "needs_attention",
|
|
612
|
-
});
|
|
613
|
-
if (changed)
|
|
614
|
-
this.cache.remove(panel);
|
|
615
|
-
return changed;
|
|
616
|
-
}
|
|
617
|
-
async handleChannelLifecycle(input) {
|
|
618
|
-
const repository = this.repository(input.services);
|
|
619
|
-
const candidates = await repository.listByChannel(input.guildId, input.channelId);
|
|
620
|
-
const inspection = input.deleted
|
|
621
|
-
? null
|
|
622
|
-
: await input.services
|
|
623
|
-
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
624
|
-
.inspectChannel({
|
|
625
|
-
guildId: input.guildId,
|
|
626
|
-
channelId: input.channelId,
|
|
627
|
-
});
|
|
628
|
-
let changed = 0;
|
|
629
|
-
for (const panel of candidates) {
|
|
630
|
-
if (input.deleted ||
|
|
631
|
-
!inspection ||
|
|
632
|
-
!channelSupportsPanel(inspection, panel))
|
|
633
|
-
changed += (await this.markNeedsAttention(panel, input.services))
|
|
634
|
-
? 1
|
|
635
|
-
: 0;
|
|
636
|
-
}
|
|
637
|
-
return changed;
|
|
272
|
+
markMessageMissing(input) {
|
|
273
|
+
return this.#lifecycle.markMessageMissing(input);
|
|
638
274
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const candidates = await repository.listByRole(input.guildId, input.roleId);
|
|
642
|
-
const inspection = input.deleted
|
|
643
|
-
? null
|
|
644
|
-
: await input.services
|
|
645
|
-
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
646
|
-
.inspectRole({
|
|
647
|
-
guildId: input.guildId,
|
|
648
|
-
roleId: input.roleId,
|
|
649
|
-
});
|
|
650
|
-
if (!input.deleted &&
|
|
651
|
-
inspection?.exists &&
|
|
652
|
-
inspection.guildMatches &&
|
|
653
|
-
inspection.assignable &&
|
|
654
|
-
!inspection.managed &&
|
|
655
|
-
!inspection.everyone &&
|
|
656
|
-
inspection.dangerousPermissionNames.length === 0)
|
|
657
|
-
return 0;
|
|
658
|
-
let changed = 0;
|
|
659
|
-
for (const panel of candidates)
|
|
660
|
-
changed += (await this.markNeedsAttention(panel, input.services)) ? 1 : 0;
|
|
661
|
-
return changed;
|
|
275
|
+
markNeedsAttention(panel, services) {
|
|
276
|
+
return this.#lifecycle.markNeedsAttention(panel, services);
|
|
662
277
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const resources = input.services.get(HELYX_SERVICE_NAMES.discordResources);
|
|
666
|
-
let changed = 0;
|
|
667
|
-
for (const panel of candidates) {
|
|
668
|
-
const entry = panel.entries.find(({ emoji }) => emoji && canonicaliseEmoji(emoji).id === input.emojiId);
|
|
669
|
-
const invalid = input.deleted ||
|
|
670
|
-
!entry?.emoji ||
|
|
671
|
-
!(await resources.inspectEmoji({
|
|
672
|
-
guildId: input.guildId,
|
|
673
|
-
emojiIdentifier: entry.emoji,
|
|
674
|
-
})).usable;
|
|
675
|
-
if (invalid)
|
|
676
|
-
changed += (await this.markNeedsAttention(panel, input.services))
|
|
677
|
-
? 1
|
|
678
|
-
: 0;
|
|
679
|
-
}
|
|
680
|
-
return changed;
|
|
278
|
+
handleChannelLifecycle(input) {
|
|
279
|
+
return this.#lifecycle.handleChannelLifecycle(input);
|
|
681
280
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
await audit.append({
|
|
685
|
-
guildId: context.guildId,
|
|
686
|
-
actorUserId: context.actorUserId,
|
|
687
|
-
action,
|
|
688
|
-
source: context.source,
|
|
689
|
-
...(context.correlationId
|
|
690
|
-
? { correlationId: context.correlationId }
|
|
691
|
-
: {}),
|
|
692
|
-
moduleId: MODULE_ID,
|
|
693
|
-
targetType: "reaction_role_panel",
|
|
694
|
-
targetId: panel.panelId,
|
|
695
|
-
metadata: {
|
|
696
|
-
channelId: panel.channelId,
|
|
697
|
-
revision: panel.revision,
|
|
698
|
-
controlType: panel.controlType,
|
|
699
|
-
status: panel.status,
|
|
700
|
-
entryCount: panel.entries.length,
|
|
701
|
-
result,
|
|
702
|
-
},
|
|
703
|
-
});
|
|
281
|
+
handleRoleLifecycle(input) {
|
|
282
|
+
return this.#lifecycle.handleRoleLifecycle(input);
|
|
704
283
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
return {
|
|
708
|
-
guildId: context.guildId,
|
|
709
|
-
actorUserId: context.actor.userId,
|
|
710
|
-
source: "dashboard",
|
|
711
|
-
services: context.services,
|
|
712
|
-
correlationId: context.correlationId,
|
|
713
|
-
sendToChannel: (channelId, response) => context.sendToChannel(channelId, response),
|
|
714
|
-
deleteChannelMessage: (channelId, messageId) => context.deleteChannelMessage(channelId, messageId),
|
|
715
|
-
editChannelMessage: (channelId, messageId, response) => context.editChannelMessage(channelId, messageId, response),
|
|
716
|
-
addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => context.addChannelMessageReaction(channelId, messageId, emojiIdentifier),
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
export function parseManagedDraft(value) {
|
|
720
|
-
const entriesValue = value.entries;
|
|
721
|
-
if (!Array.isArray(entriesValue))
|
|
722
|
-
throw new DashboardActionValidationError("Add at least one role entry before publishing");
|
|
723
|
-
const entries = entriesValue.map((item, index) => {
|
|
724
|
-
if (!isManagedObject(item))
|
|
725
|
-
throw new DashboardActionValidationError(`Role entry ${index + 1} is invalid`);
|
|
726
|
-
return {
|
|
727
|
-
entryId: optionalString(item.entryId) ??
|
|
728
|
-
`entry-${randomUUID().replaceAll("-", "")}`,
|
|
729
|
-
roleId: requiredManagedString(item.roleId, "Role"),
|
|
730
|
-
nameplate: requiredManagedString(item.nameplate, "Role label"),
|
|
731
|
-
...(optionalString(item.emoji)
|
|
732
|
-
? { emoji: optionalString(item.emoji) }
|
|
733
|
-
: {}),
|
|
734
|
-
...(optionalString(item.buttonStyle)
|
|
735
|
-
? {
|
|
736
|
-
buttonStyle: requiredLiteral(optionalString(item.buttonStyle), ["primary", "secondary", "success", "danger"], "button colour"),
|
|
737
|
-
}
|
|
738
|
-
: {}),
|
|
739
|
-
};
|
|
740
|
-
});
|
|
741
|
-
try {
|
|
742
|
-
return validatePanelDraft({
|
|
743
|
-
channelId: requiredManagedString(value.channelId, "Destination channel"),
|
|
744
|
-
title: requiredManagedString(value.title, "Title"),
|
|
745
|
-
messageBody: requiredManagedString(value.messageBody, "Message"),
|
|
746
|
-
messageStyle: requiredLiteral(requiredManagedString(value.messageStyle, "Message style"), ["text", "embed"], "message style"),
|
|
747
|
-
controlType: requiredLiteral(requiredManagedString(value.controlType, "Role controls"), ["buttons", "dropdown", "reactions"], "role controls"),
|
|
748
|
-
selectionMode: requiredLiteral(requiredManagedString(value.selectionMode, "Selection mode"), ["multiple", "one_only"], "selection mode"),
|
|
749
|
-
persistentReactions: typeof value.persistentReactions === "boolean"
|
|
750
|
-
? value.persistentReactions
|
|
751
|
-
: true,
|
|
752
|
-
...(typeof value.embedColour === "number"
|
|
753
|
-
? { embedColour: value.embedColour }
|
|
754
|
-
: {}),
|
|
755
|
-
...(optionalString(value.dropdownPlaceholder)
|
|
756
|
-
? { dropdownPlaceholder: optionalString(value.dropdownPlaceholder) }
|
|
757
|
-
: {}),
|
|
758
|
-
entries,
|
|
759
|
-
});
|
|
284
|
+
handleEmojiLifecycle(input) {
|
|
285
|
+
return this.#lifecycle.handleEmojiLifecycle(input);
|
|
760
286
|
}
|
|
761
|
-
catch (error) {
|
|
762
|
-
if (error instanceof DashboardActionValidationError)
|
|
763
|
-
throw error;
|
|
764
|
-
throw new DashboardActionValidationError(error instanceof Error ? error.message : "Role panel draft is invalid");
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
export function toDetail(panel) {
|
|
768
|
-
return {
|
|
769
|
-
...toSummary(panel),
|
|
770
|
-
value: {
|
|
771
|
-
channelId: panel.channelId,
|
|
772
|
-
title: panel.title,
|
|
773
|
-
messageBody: panel.messageBody,
|
|
774
|
-
messageStyle: panel.messageStyle,
|
|
775
|
-
controlType: panel.controlType,
|
|
776
|
-
selectionMode: panel.selectionMode,
|
|
777
|
-
persistentReactions: panel.persistentReactions,
|
|
778
|
-
...(panel.embedColour === undefined
|
|
779
|
-
? {}
|
|
780
|
-
: { embedColour: panel.embedColour }),
|
|
781
|
-
...(panel.dropdownPlaceholder === undefined
|
|
782
|
-
? {}
|
|
783
|
-
: { dropdownPlaceholder: panel.dropdownPlaceholder }),
|
|
784
|
-
entries: panel.entries.map((entry) => ({
|
|
785
|
-
entryId: entry.entryId,
|
|
786
|
-
roleId: entry.roleId,
|
|
787
|
-
nameplate: entry.nameplate,
|
|
788
|
-
...(entry.emoji ? { emoji: entry.emoji } : {}),
|
|
789
|
-
...(entry.buttonStyle ? { buttonStyle: entry.buttonStyle } : {}),
|
|
790
|
-
})),
|
|
791
|
-
},
|
|
792
|
-
};
|
|
793
|
-
}
|
|
794
|
-
function toSummary(panel) {
|
|
795
|
-
return {
|
|
796
|
-
id: panel.panelId,
|
|
797
|
-
revision: panel.revision,
|
|
798
|
-
status: panel.status,
|
|
799
|
-
title: panel.title,
|
|
800
|
-
description: `${panel.entries.length} role ${panel.entries.length === 1 ? "entry" : "entries"} · ${panel.controlType}`,
|
|
801
|
-
updatedAt: panel.updatedAt.toISOString(),
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
async function validateDiscordResources(services, guildId, actorUserId, draft) {
|
|
805
|
-
const resources = services.get(HELYX_SERVICE_NAMES.discordResources);
|
|
806
|
-
const channel = await resources.inspectChannel({
|
|
807
|
-
guildId,
|
|
808
|
-
channelId: draft.channelId,
|
|
809
|
-
});
|
|
810
|
-
if (!channel.exists || !channel.guildMatches || !channel.viewable)
|
|
811
|
-
throw new DashboardActionValidationError("Helyx cannot view the selected destination channel");
|
|
812
|
-
if (!channel.sendMessages)
|
|
813
|
-
throw new DashboardActionValidationError("Helyx cannot send messages in the selected destination channel");
|
|
814
|
-
if (draft.messageStyle === "embed" &&
|
|
815
|
-
!channel.effectivePermissionNames.includes("EMBED_LINKS"))
|
|
816
|
-
throw new DashboardActionValidationError("Helyx needs Embed Links in the selected destination channel");
|
|
817
|
-
if (draft.controlType === "reactions" &&
|
|
818
|
-
(!channel.addReactions || !channel.readMessageHistory))
|
|
819
|
-
throw new DashboardActionValidationError("Emoji-reaction panels need Add Reactions and Read Message History in the selected channel");
|
|
820
|
-
if (draft.controlType === "reactions" &&
|
|
821
|
-
!draft.persistentReactions &&
|
|
822
|
-
!channel.effectivePermissionNames.includes("MANAGE_MESSAGES"))
|
|
823
|
-
throw new DashboardActionValidationError("Non-persistent emoji reactions need Manage Messages in the selected channel");
|
|
824
|
-
for (const entry of draft.entries) {
|
|
825
|
-
const role = await resources.inspectRole({
|
|
826
|
-
guildId,
|
|
827
|
-
roleId: entry.roleId,
|
|
828
|
-
actorUserId,
|
|
829
|
-
});
|
|
830
|
-
if (!role.exists || !role.guildMatches)
|
|
831
|
-
throw new DashboardActionValidationError(`The role for "${entry.nameplate}" is no longer available in this server`);
|
|
832
|
-
if (role.everyone)
|
|
833
|
-
throw new DashboardActionValidationError(`@everyone cannot be made self-assignable for "${entry.nameplate}"`);
|
|
834
|
-
if (role.managed)
|
|
835
|
-
throw new DashboardActionValidationError(`The role for "${entry.nameplate}" is managed by Discord or an integration and cannot be assigned`);
|
|
836
|
-
if (role.dangerousPermissionNames.length > 0)
|
|
837
|
-
throw new DashboardActionValidationError(`The role for "${entry.nameplate}" grants administrative or moderation permissions and cannot be self-assigned`);
|
|
838
|
-
if (role.hierarchy === "missing-manage-roles")
|
|
839
|
-
throw new DashboardActionValidationError(`Helyx needs Manage Roles before it can assign "${entry.nameplate}"`);
|
|
840
|
-
if (role.hierarchy === "above-helyx" || !role.assignable)
|
|
841
|
-
throw new DashboardActionValidationError(`Move Helyx's highest role above the role for "${entry.nameplate}", then try again`);
|
|
842
|
-
if (role.hierarchy === "above-actor" || !role.actorAssignable)
|
|
843
|
-
throw new DashboardActionValidationError(`Your highest role must be above the role for "${entry.nameplate}"`);
|
|
844
|
-
if (entry.emoji) {
|
|
845
|
-
const emoji = await resources.inspectEmoji({
|
|
846
|
-
guildId,
|
|
847
|
-
emojiIdentifier: entry.emoji,
|
|
848
|
-
});
|
|
849
|
-
if (!emoji.exists || !emoji.guildMatches || !emoji.usable)
|
|
850
|
-
throw new DashboardActionValidationError(`The emoji for "${entry.nameplate}" is not available in this server`);
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
function channelSupportsPanel(channel, panel) {
|
|
855
|
-
return (channel.exists &&
|
|
856
|
-
channel.guildMatches &&
|
|
857
|
-
channel.viewable &&
|
|
858
|
-
channel.sendMessages &&
|
|
859
|
-
(panel.messageStyle !== "embed" ||
|
|
860
|
-
channel.effectivePermissionNames.includes("EMBED_LINKS")) &&
|
|
861
|
-
(panel.controlType !== "reactions" ||
|
|
862
|
-
(channel.addReactions &&
|
|
863
|
-
channel.readMessageHistory &&
|
|
864
|
-
(panel.persistentReactions ||
|
|
865
|
-
channel.effectivePermissionNames.includes("MANAGE_MESSAGES")))));
|
|
866
|
-
}
|
|
867
|
-
function prepareEntries(entries, existing) {
|
|
868
|
-
return entries.map((entry) => {
|
|
869
|
-
const prior = existing?.entries.find((candidate) => candidate.roleId === entry.roleId);
|
|
870
|
-
return {
|
|
871
|
-
entryId: prior?.entryId ?? entry.entryId,
|
|
872
|
-
roleId: entry.roleId,
|
|
873
|
-
nameplate: entry.nameplate,
|
|
874
|
-
emoji: entry.emoji ? canonicaliseEmoji(entry.emoji) : null,
|
|
875
|
-
buttonStyle: entry.buttonStyle ?? null,
|
|
876
|
-
};
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
async function seedReactions(context, draft, messageId) {
|
|
880
|
-
for (const emojiIdentifier of buildPanel(context.services, draft)
|
|
881
|
-
.reactionSeeds)
|
|
882
|
-
await context.addChannelMessageReaction(draft.channelId, messageId, emojiIdentifier);
|
|
883
|
-
}
|
|
884
|
-
function buildPanel(services, draft) {
|
|
885
|
-
return services
|
|
886
|
-
.get(HELYX_SERVICE_NAMES.messageBuilder)
|
|
887
|
-
.build(panelMessageDraft(draft));
|
|
888
|
-
}
|
|
889
|
-
function requiresReplacement(current, draft) {
|
|
890
|
-
if (current.channelId !== draft.channelId)
|
|
891
|
-
return true;
|
|
892
|
-
if (current.controlType !== "reactions" && draft.controlType !== "reactions")
|
|
893
|
-
return false;
|
|
894
|
-
if (current.controlType !== draft.controlType)
|
|
895
|
-
return true;
|
|
896
|
-
return (current.entries.some((entry, index) => {
|
|
897
|
-
const next = draft.entries[index];
|
|
898
|
-
return (!next || entry.roleId !== next.roleId || entry.emoji !== next.emoji);
|
|
899
|
-
}) || current.entries.length !== draft.entries.length);
|
|
900
|
-
}
|
|
901
|
-
async function emitLog(services, guildId, input) {
|
|
902
|
-
if (!services.has(HELYX_SERVICE_NAMES.moduleLogging))
|
|
903
|
-
return;
|
|
904
|
-
await services
|
|
905
|
-
.get(HELYX_SERVICE_NAMES.moduleLogging)
|
|
906
|
-
.emit({
|
|
907
|
-
guildId,
|
|
908
|
-
moduleId: MODULE_ID,
|
|
909
|
-
eventId: input.eventId,
|
|
910
|
-
summary: input.summary,
|
|
911
|
-
details: [{ label: "Panel", value: input.panelId }],
|
|
912
|
-
})
|
|
913
|
-
.catch(() => undefined);
|
|
914
|
-
}
|
|
915
|
-
function encodeCursor(cursor) {
|
|
916
|
-
return Buffer.from(JSON.stringify(cursor.map((value) => value instanceof Date ? { date: value.toISOString() } : { value }))).toString("base64url");
|
|
917
|
-
}
|
|
918
|
-
function decodeCursor(cursor) {
|
|
919
|
-
try {
|
|
920
|
-
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
921
|
-
if (!Array.isArray(parsed) || parsed.length !== 2)
|
|
922
|
-
throw new Error("invalid");
|
|
923
|
-
return parsed.map((item) => {
|
|
924
|
-
if (!isManagedObject(item))
|
|
925
|
-
throw new Error("invalid");
|
|
926
|
-
if (typeof item.date === "string") {
|
|
927
|
-
const date = new Date(item.date);
|
|
928
|
-
if (Number.isNaN(date.getTime()))
|
|
929
|
-
throw new Error("invalid");
|
|
930
|
-
return date;
|
|
931
|
-
}
|
|
932
|
-
if (typeof item.value !== "string" &&
|
|
933
|
-
typeof item.value !== "boolean" &&
|
|
934
|
-
typeof item.value !== "number")
|
|
935
|
-
throw new Error("invalid");
|
|
936
|
-
return item.value;
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
catch {
|
|
940
|
-
throw new DashboardActionValidationError("The role-panel page cursor is invalid");
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
function cacheKey(guildId, messageId) {
|
|
944
|
-
return `${guildId}:${messageId}`;
|
|
945
|
-
}
|
|
946
|
-
function isManagedObject(value) {
|
|
947
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
948
|
-
}
|
|
949
|
-
function requiredManagedString(value, label) {
|
|
950
|
-
if (typeof value !== "string")
|
|
951
|
-
throw new DashboardActionValidationError(`${label} is required`);
|
|
952
|
-
return value;
|
|
953
|
-
}
|
|
954
|
-
function optionalString(value) {
|
|
955
|
-
return typeof value === "string" && value.trim() ? value : undefined;
|
|
956
|
-
}
|
|
957
|
-
function requiredLiteral(value, allowed, label) {
|
|
958
|
-
if (!allowed.includes(value))
|
|
959
|
-
throw new DashboardActionValidationError(`Choose a valid ${label}`);
|
|
960
|
-
return value;
|
|
961
287
|
}
|