@helyx/module-reactionroles 1.0.1

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.
@@ -0,0 +1,961 @@
1
+ import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ import { canonicaliseEmoji, panelMessageDraft, validatePanelDraft, } from "./builder.js";
4
+ import { MODULE_ID } from "./constants.js";
5
+ import { ReactionRolePanelRepository } from "./records.js";
6
+ const MAX_ACTIVE_CACHE = 10_000;
7
+ const ACTIVE_CACHE_TTL_MS = 60_000;
8
+ const NEGATIVE_CACHE_TTL_MS = 15_000;
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
+ }
185
+ export class ReactionRolePanelService {
186
+ cache;
187
+ constructor(cache) {
188
+ this.cache = cache;
189
+ }
190
+ repository(services) {
191
+ return new ReactionRolePanelRepository(services.get(HELYX_SERVICE_NAMES.records));
192
+ }
193
+ async warm(services) {
194
+ const repository = this.repository(services);
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();
212
+ }
213
+ async recoverMessageCleanup(services) {
214
+ if (!services.has(HELYX_SERVICE_NAMES.discordOwnedMessages))
215
+ return { completed: 0, failed: 0, abandoned: 0 };
216
+ const repository = this.repository(services);
217
+ const pending = await repository.listPendingCleanup(100);
218
+ const messages = services.get(HELYX_SERVICE_NAMES.discordOwnedMessages);
219
+ let completed = 0;
220
+ let failed = 0;
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 };
256
+ }
257
+ getCached(guildId, messageId) {
258
+ return this.cache.get(guildId, messageId);
259
+ }
260
+ async resolveActive(guildId, messageId, services) {
261
+ const fresh = this.cache.getFresh(guildId, messageId);
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
+ });
273
+ }
274
+ async list(context, input) {
275
+ const page = await this.repository(context.services).list({
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
+ };
284
+ }
285
+ async read(context, resourceId) {
286
+ const panel = await this.repository(context.services).findById(context.guildId, resourceId);
287
+ return panel ? toDetail(panel) : null;
288
+ }
289
+ async preview(context, value) {
290
+ const draft = parseManagedDraft(value);
291
+ await validateDiscordResources(context.services, context.guildId, context.actor.userId, draft);
292
+ return buildPanel(context.services, draft).response;
293
+ }
294
+ async create(context, draftInput) {
295
+ const draft = validatePanelDraft(draftInput);
296
+ await validateDiscordResources(context.services, context.guildId, context.actorUserId, draft);
297
+ const repository = this.repository(context.services);
298
+ const panelId = randomUUID();
299
+ const entries = prepareEntries(draft.entries);
300
+ const staged = await repository.create({
301
+ panelId,
302
+ guildId: context.guildId,
303
+ actorUserId: context.actorUserId,
304
+ draft,
305
+ entries,
306
+ });
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
+ }
372
+ async update(context, input) {
373
+ const repository = this.repository(context.services);
374
+ const current = await repository.findById(context.guildId, input.resourceId);
375
+ if (!current)
376
+ throw new DashboardActionValidationError("Role panel was not found");
377
+ if (current.revision !== input.expectedRevision)
378
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
379
+ const draft = validatePanelDraft(input.draft);
380
+ await validateDiscordResources(context.services, context.guildId, context.actorUserId, draft);
381
+ const entries = prepareEntries(draft.entries, current);
382
+ const replace = input.replacementAction !== undefined ||
383
+ current.status === "message_missing" ||
384
+ requiresReplacement(current, draft);
385
+ let cleanupPending = false;
386
+ const resumeCache = this.cache.suspend(current);
387
+ let safeFallback = current;
388
+ let saved;
389
+ try {
390
+ if (!current.messageId || replace) {
391
+ const replacement = await context.sendToChannel(draft.channelId, buildPanel(context.services, draft).response);
392
+ try {
393
+ await seedReactions(context, draft, replacement.messageId);
394
+ safeFallback = null;
395
+ const applied = await repository.update({
396
+ panel: current,
397
+ actorUserId: context.actorUserId,
398
+ draft,
399
+ messageId: replacement.messageId,
400
+ status: "active",
401
+ entries,
402
+ });
403
+ if (!applied) {
404
+ safeFallback = await repository.findById(context.guildId, current.panelId);
405
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
406
+ }
407
+ }
408
+ catch (error) {
409
+ await context
410
+ .deleteChannelMessage(draft.channelId, replacement.messageId)
411
+ .catch(() => undefined);
412
+ throw error;
413
+ }
414
+ if (current.messageId && current.status !== "message_missing") {
415
+ try {
416
+ await context.deleteChannelMessage(current.channelId, current.messageId);
417
+ }
418
+ catch {
419
+ cleanupPending = true;
420
+ await repository.recordCleanup({
421
+ guildId: context.guildId,
422
+ panelId: current.panelId,
423
+ channelId: current.channelId,
424
+ messageId: current.messageId,
425
+ reason: "replacement",
426
+ });
427
+ }
428
+ }
429
+ }
430
+ else {
431
+ await context.editChannelMessage(current.channelId, current.messageId, buildPanel(context.services, draft).response);
432
+ let applied;
433
+ try {
434
+ safeFallback = null;
435
+ applied = await repository.update({
436
+ panel: current,
437
+ actorUserId: context.actorUserId,
438
+ draft,
439
+ messageId: current.messageId,
440
+ status: "active",
441
+ entries,
442
+ });
443
+ }
444
+ catch (error) {
445
+ await context
446
+ .editChannelMessage(current.channelId, current.messageId, buildPanel(context.services, current).response)
447
+ .catch(() => undefined);
448
+ throw error;
449
+ }
450
+ if (!applied) {
451
+ const latest = await repository.findById(context.guildId, current.panelId);
452
+ safeFallback = latest;
453
+ if (latest?.messageId === current.messageId)
454
+ await context
455
+ .editChannelMessage(latest.channelId, latest.messageId, buildPanel(context.services, latest).response)
456
+ .catch(() => undefined);
457
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
458
+ }
459
+ }
460
+ const reloaded = await repository.findById(context.guildId, current.panelId);
461
+ if (!reloaded)
462
+ throw new Error("Updated role panel could not be reloaded");
463
+ saved = reloaded;
464
+ if (cleanupPending) {
465
+ const marked = await repository.markStatus({
466
+ panelId: saved.panelId,
467
+ expectedRevision: saved.revision,
468
+ actorUserId: context.actorUserId,
469
+ status: "cleanup_pending",
470
+ });
471
+ if (marked) {
472
+ saved =
473
+ (await repository.findById(context.guildId, current.panelId)) ??
474
+ saved;
475
+ }
476
+ }
477
+ resumeCache(saved);
478
+ }
479
+ catch (error) {
480
+ resumeCache(safeFallback);
481
+ throw error;
482
+ }
483
+ await this.audit(context, "reactionroles.panel.updated", saved, cleanupPending ? "cleanup_pending" : "success");
484
+ await emitLog(context.services, context.guildId, {
485
+ eventId: "reaction-role-panel-updated",
486
+ summary: "A Reaction Roles panel was updated.",
487
+ panelId: saved.panelId,
488
+ });
489
+ if (input.replacementAction === "repair") {
490
+ await this.audit(context, "reactionroles.panel.repaired", saved);
491
+ await emitLog(context.services, context.guildId, {
492
+ eventId: "reaction-role-panel-repaired",
493
+ summary: "A Reaction Roles panel was repaired.",
494
+ panelId: saved.panelId,
495
+ });
496
+ }
497
+ else if (replace) {
498
+ await this.audit(context, "reactionroles.panel.reposted", saved);
499
+ await emitLog(context.services, context.guildId, {
500
+ eventId: "reaction-role-panel-reposted",
501
+ summary: "A Reaction Roles panel was reposted.",
502
+ panelId: saved.panelId,
503
+ });
504
+ }
505
+ if (cleanupPending)
506
+ await this.audit(context, "reactionroles.panel.cleanup.failed", saved, "cleanup_pending");
507
+ return saved;
508
+ }
509
+ async delete(context, panelId, expectedRevision) {
510
+ const repository = this.repository(context.services);
511
+ const panel = await repository.findById(context.guildId, panelId);
512
+ if (!panel)
513
+ return false;
514
+ if (panel.revision !== expectedRevision)
515
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
516
+ const claimed = await repository.claimDeleting(panel.panelId, panel.revision, context.actorUserId);
517
+ if (!claimed)
518
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
519
+ this.cache.remove(panel);
520
+ let cleanupPending = false;
521
+ if (panel.messageId) {
522
+ try {
523
+ await context.deleteChannelMessage(panel.channelId, panel.messageId);
524
+ }
525
+ catch {
526
+ cleanupPending = await repository.recordCleanup({
527
+ guildId: context.guildId,
528
+ panelId: panel.panelId,
529
+ channelId: panel.channelId,
530
+ messageId: panel.messageId,
531
+ reason: "delete",
532
+ });
533
+ }
534
+ }
535
+ if (!(await repository.deleteClaimed(panel.panelId)))
536
+ throw new Error("The claimed role panel could not be deleted");
537
+ await this.audit(context, "reactionroles.panel.deleted", panel, cleanupPending ? "cleanup_pending" : "success");
538
+ if (cleanupPending)
539
+ await this.audit(context, "reactionroles.panel.cleanup.failed", panel, "cleanup_pending");
540
+ await emitLog(context.services, context.guildId, {
541
+ eventId: "reaction-role-panel-deleted",
542
+ summary: "A Reaction Roles panel was deleted.",
543
+ panelId,
544
+ });
545
+ return true;
546
+ }
547
+ async repair(context, panelId, expectedRevision, repost) {
548
+ await this.recoverMessageCleanup(context.services);
549
+ const repository = this.repository(context.services);
550
+ const panel = await repository.findById(context.guildId, panelId);
551
+ if (!panel)
552
+ throw new DashboardActionValidationError("Role panel was not found");
553
+ if (panel.revision !== expectedRevision)
554
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
555
+ await validateDiscordResources(context.services, context.guildId, context.actorUserId, panel);
556
+ if (repost || !panel.messageId || panel.status === "message_missing")
557
+ return this.update(context, {
558
+ resourceId: panel.panelId,
559
+ expectedRevision: panel.revision,
560
+ draft: panel,
561
+ replacementAction: repost ? "repost" : "repair",
562
+ });
563
+ await context.editChannelMessage(panel.channelId, panel.messageId, buildPanel(context.services, panel).response);
564
+ await seedReactions(context, panel, panel.messageId);
565
+ const repaired = await repository.markStatus({
566
+ panelId,
567
+ expectedRevision,
568
+ actorUserId: context.actorUserId,
569
+ status: (await repository.pendingCleanupCount(panelId)) > 0
570
+ ? "cleanup_pending"
571
+ : "active",
572
+ });
573
+ if (!repaired)
574
+ throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
575
+ const saved = await repository.findById(context.guildId, panelId);
576
+ if (!saved)
577
+ throw new Error("Repaired role panel could not be reloaded");
578
+ this.cache.put(saved);
579
+ await this.audit(context, "reactionroles.panel.repaired", saved);
580
+ await emitLog(context.services, context.guildId, {
581
+ eventId: "reaction-role-panel-repaired",
582
+ summary: "A Reaction Roles panel was repaired.",
583
+ panelId,
584
+ });
585
+ return saved;
586
+ }
587
+ async markMessageMissing(input) {
588
+ const repository = this.repository(input.services);
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;
638
+ }
639
+ async handleRoleLifecycle(input) {
640
+ const repository = this.repository(input.services);
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;
662
+ }
663
+ async handleEmojiLifecycle(input) {
664
+ const candidates = await this.repository(input.services).listByEmoji(input.guildId, input.emojiId);
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;
681
+ }
682
+ async audit(context, action, panel, result = "success") {
683
+ const audit = context.services.get(HELYX_SERVICE_NAMES.audit);
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
+ });
704
+ }
705
+ }
706
+ export function managedContext(context) {
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
+ });
760
+ }
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
+ }