@helyx/module-reactionroles 1.0.14 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +37 -53
  3. package/dist/builder-interactions.d.ts +11 -0
  4. package/dist/builder-interactions.js +293 -0
  5. package/dist/builder-presentation.d.ts +30 -0
  6. package/dist/builder-presentation.js +343 -0
  7. package/dist/builder.d.ts +3 -0
  8. package/dist/builder.js +22 -16
  9. package/dist/cleanup-repository.d.ts +24 -0
  10. package/dist/cleanup-repository.js +118 -0
  11. package/dist/constants.d.ts +3 -0
  12. package/dist/constants.js +4 -1
  13. package/dist/health.d.ts +16 -0
  14. package/dist/health.js +85 -0
  15. package/dist/index.js +66 -1147
  16. package/dist/logging.d.ts +33 -0
  17. package/dist/logging.js +40 -0
  18. package/dist/managed-resource.d.ts +2 -0
  19. package/dist/managed-resource.js +72 -0
  20. package/dist/member-interactions.d.ts +27 -0
  21. package/dist/member-interactions.js +360 -0
  22. package/dist/module-lifecycle.d.ts +3 -0
  23. package/dist/module-lifecycle.js +48 -0
  24. package/dist/panel-cache.d.ts +30 -0
  25. package/dist/panel-cache.js +197 -0
  26. package/dist/panel-create.d.ts +10 -0
  27. package/dist/panel-create.js +80 -0
  28. package/dist/panel-lifecycle.d.ts +47 -0
  29. package/dist/panel-lifecycle.js +272 -0
  30. package/dist/panel-managed.d.ts +14 -0
  31. package/dist/panel-managed.js +27 -0
  32. package/dist/panel-service.d.ts +13 -36
  33. package/dist/panel-service.js +48 -722
  34. package/dist/panel-support.d.ts +35 -0
  35. package/dist/panel-support.js +291 -0
  36. package/dist/permissions.d.ts +2 -0
  37. package/dist/permissions.js +8 -0
  38. package/dist/privacy.d.ts +5 -0
  39. package/dist/privacy.js +12 -0
  40. package/dist/record-mapping.d.ts +28 -0
  41. package/dist/record-mapping.js +161 -0
  42. package/dist/record-schema.d.ts +203 -0
  43. package/dist/record-schema.js +137 -0
  44. package/dist/records.d.ts +9 -201
  45. package/dist/records.js +32 -383
  46. package/dist/runtime-state.d.ts +9 -0
  47. package/dist/runtime-state.js +30 -0
  48. package/manifest.json +9 -2
  49. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,83 +1,17 @@
1
1
  import { parseModuleManifest } from "@helyx/module-manifest";
2
- import { createPrivacyProvider, DashboardActionValidationError, defineModule, getModulePrivacyDeclaration, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { defineModule } from "@helyx/sdk";
3
3
  import { readFileSync } from "node:fs";
4
- import { randomUUID } from "node:crypto";
5
- import { PANEL_LIMITS, COMPONENT_IDS, MODULE_ID, PANEL_PURPOSE, } from "./constants.js";
6
- import { canonicaliseEmoji, panelMessageDraft, validatePanelDraft, } from "./builder.js";
7
- import { PanelSnapshotCache, ReactionRolePanelService, managedContext, parseManagedDraft, toDetail, } from "./panel-service.js";
4
+ import { COMPONENT_IDS, REACTION_ROLE_CLEANUP_TASK } from "./constants.js";
8
5
  import { reactionRoleRecords } from "./records.js";
6
+ import { cancelDraft, confirmDelete, continueDraft, keepDraft, openEditEntryModal, openEntryModal, publishDraft, replaceDraft, saveBasicDraft, saveEntry, } from "./builder-interactions.js";
7
+ import { handleReactionAdd, handleReactionRemove, reconcileSelection, removeAllPanelRoles, toggleSlot, } from "./member-interactions.js";
8
+ import { managedPanels } from "./managed-resource.js";
9
+ import { reactionRoleLogging } from "./logging.js";
10
+ import { startReactionRoles, stopReactionRoles } from "./module-lifecycle.js";
11
+ import { createReactionRolePrivacy } from "./privacy.js";
12
+ import { reactionRoleManagerPolicy as managerPolicy } from "./permissions.js";
13
+ import { reactionRoleHealth as moduleHealth, reactionRolePanels as panels, runReactionRoleFailureOnly as runFailureOnly, runReactionRolePrimary as runPrimary, } from "./runtime-state.js";
9
14
  const manifest = parseModuleManifest(JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")));
10
- const cache = new PanelSnapshotCache();
11
- const panels = new ReactionRolePanelService(cache);
12
- const memberLocks = new Map();
13
- let healthStatus = "healthy";
14
- let healthMessage = "Reaction Roles is ready";
15
- const managerPolicy = {
16
- everyone: false,
17
- requiredPermissions: ["MANAGE_GUILD"],
18
- allowedRoleIds: [],
19
- deniedRoleIds: [],
20
- allowedUserIds: [],
21
- deniedUserIds: [],
22
- };
23
- const managedPanels = {
24
- id: "panels",
25
- list: (context, input) => panels.list(context, input),
26
- read: (context, resourceId) => panels.read(context, resourceId),
27
- preview: (context, value) => panels.preview(context, value),
28
- async create(context, value) {
29
- const panel = await panels.create(managedContext(context), parseManagedDraft(value));
30
- return toDetail(panel);
31
- },
32
- async update(context, input) {
33
- const panel = await panels.update(managedContext(context), {
34
- resourceId: input.resourceId,
35
- expectedRevision: input.expectedRevision,
36
- draft: parseManagedDraft(input.value),
37
- });
38
- return toDetail(panel);
39
- },
40
- delete: (context, input) => panels
41
- .delete(managedContext(context), input.resourceId, input.expectedRevision)
42
- .then((deleted) => ({ deleted })),
43
- async executeAction(context, input) {
44
- if (input.actionId === "duplicate") {
45
- const current = await panels
46
- .repository(context.services)
47
- .findById(context.guildId, input.resourceId);
48
- if (!current)
49
- throw new DashboardActionValidationError("Role panel was not found");
50
- if (current.revision !== input.expectedRevision)
51
- throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
52
- return toDetail(await panels.create(managedContext(context), {
53
- channelId: current.channelId,
54
- title: current.title,
55
- messageBody: current.messageBody,
56
- messageStyle: current.messageStyle,
57
- controlType: current.controlType,
58
- selectionMode: current.selectionMode,
59
- persistentReactions: current.persistentReactions,
60
- ...(current.embedColour === undefined
61
- ? {}
62
- : { embedColour: current.embedColour }),
63
- ...(current.dropdownPlaceholder === undefined
64
- ? {}
65
- : { dropdownPlaceholder: current.dropdownPlaceholder }),
66
- entries: current.entries.map((entry) => ({
67
- ...entry,
68
- entryId: `entry-${randomUUID().replaceAll("-", "")}`,
69
- })),
70
- }));
71
- }
72
- if (input.actionId === "delete") {
73
- await panels.delete(managedContext(context), input.resourceId, input.expectedRevision);
74
- return null;
75
- }
76
- if (input.actionId !== "repair" && input.actionId !== "repost")
77
- throw new Error("Unknown Reaction Roles resource action");
78
- return toDetail(await panels.repair(managedContext(context), input.resourceId, input.expectedRevision, input.actionId === "repost"));
79
- },
80
- };
81
15
  export const reactionRolesModule = defineModule({
82
16
  manifest,
83
17
  defaultPermissions: {
@@ -110,7 +44,7 @@ export const reactionRolesModule = defineModule({
110
44
  {
111
45
  customId: COMPONENT_IDS.publish,
112
46
  acknowledgement: "deferred-ephemeral",
113
- execute: publishDraft,
47
+ execute: (context) => runPrimary(() => publishDraft(context)),
114
48
  },
115
49
  {
116
50
  customId: COMPONENT_IDS.cancel,
@@ -119,7 +53,7 @@ export const reactionRolesModule = defineModule({
119
53
  {
120
54
  customId: COMPONENT_IDS.deleteConfirm,
121
55
  acknowledgement: "deferred-ephemeral",
122
- execute: confirmDelete,
56
+ execute: (context) => runPrimary(() => confirmDelete(context)),
123
57
  },
124
58
  ...COMPONENT_IDS.slots.map((customId, index) => ({
125
59
  customId,
@@ -152,1147 +86,132 @@ export const reactionRolesModule = defineModule({
152
86
  messageReactionAdd: [
153
87
  {
154
88
  type: "discord.message-reaction-add",
155
- execute: handleReactionAdd,
89
+ execute: (context) => runFailureOnly(() => handleReactionAdd(context), "member"),
156
90
  },
157
91
  ],
158
92
  messageReactionRemove: [
159
93
  {
160
94
  type: "discord.message-reaction-remove",
161
- execute: handleReactionRemove,
95
+ execute: (context) => runFailureOnly(() => handleReactionRemove(context), "member"),
162
96
  },
163
97
  ],
164
98
  messageDelete: [
165
99
  {
166
100
  type: "discord.message-delete",
167
- execute: (context) => panels.markMessageMissing({
101
+ execute: (context) => runPrimary(() => panels.markMessageMissing({
168
102
  guildId: context.serverId,
169
103
  messageId: context.messageId,
170
104
  services: context.services,
171
- }),
105
+ }), "lifecycle"),
172
106
  },
173
107
  ],
174
108
  guildRoleDelete: [
175
109
  {
176
110
  type: "discord.guild-role-delete",
177
- execute: (context) => panels
178
- .handleRoleLifecycle({
111
+ execute: (context) => runPrimary(() => panels.handleRoleLifecycle({
179
112
  guildId: context.serverId,
180
113
  roleId: context.roleId,
181
114
  deleted: true,
182
115
  services: context.services,
183
- })
184
- .then(() => undefined),
116
+ }), "lifecycle").then(() => undefined),
185
117
  },
186
118
  ],
187
119
  guildRoleUpdate: [
188
120
  {
189
121
  type: "discord.guild-role-update",
190
- execute: (context) => panels
191
- .handleRoleLifecycle({
122
+ execute: (context) => runPrimary(() => panels.handleRoleLifecycle({
192
123
  guildId: context.serverId,
193
124
  roleId: context.roleId,
194
125
  deleted: false,
195
126
  services: context.services,
196
- })
197
- .then(() => undefined),
127
+ }), "lifecycle").then(() => undefined),
198
128
  },
199
129
  ],
200
130
  channelDelete: [
201
131
  {
202
132
  type: "discord.channel-delete",
203
- execute: (context) => panels
204
- .handleChannelLifecycle({
133
+ execute: (context) => runPrimary(() => panels.handleChannelLifecycle({
205
134
  guildId: context.serverId,
206
135
  channelId: context.channelId,
207
136
  deleted: true,
208
137
  services: context.services,
209
- })
210
- .then(() => undefined),
138
+ }), "lifecycle").then(() => undefined),
211
139
  },
212
140
  ],
213
141
  channelUpdate: [
214
142
  {
215
143
  type: "discord.channel-update",
216
- execute: (context) => panels
217
- .handleChannelLifecycle({
144
+ execute: (context) => runPrimary(() => panels.handleChannelLifecycle({
218
145
  guildId: context.serverId,
219
146
  channelId: context.channelId,
220
147
  deleted: false,
221
148
  services: context.services,
222
- })
223
- .then(() => undefined),
149
+ }), "lifecycle").then(() => undefined),
224
150
  },
225
151
  ],
226
152
  guildEmojiDelete: [
227
153
  {
228
154
  type: "discord.guild-emoji-delete",
229
- execute: (context) => panels
230
- .handleEmojiLifecycle({
155
+ execute: (context) => runPrimary(() => panels.handleEmojiLifecycle({
231
156
  guildId: context.serverId,
232
157
  emojiId: context.emojiId,
233
158
  deleted: true,
234
159
  services: context.services,
235
- })
236
- .then(() => undefined),
160
+ }), "lifecycle").then(() => undefined),
237
161
  },
238
162
  ],
239
163
  guildEmojiUpdate: [
240
164
  {
241
165
  type: "discord.guild-emoji-update",
242
- execute: (context) => panels
243
- .handleEmojiLifecycle({
166
+ execute: (context) => runPrimary(() => panels.handleEmojiLifecycle({
244
167
  guildId: context.serverId,
245
168
  emojiId: context.emojiId,
246
169
  deleted: false,
247
170
  services: context.services,
248
- })
249
- .then(() => undefined),
250
- },
251
- ],
252
- },
253
- logging: {
254
- events: [
255
- {
256
- id: "reaction-role-panel-created",
257
- name: "Role panel created",
258
- description: "A server manager created a Reaction Roles panel.",
259
- defaultEnabled: true,
260
- },
261
- {
262
- id: "reaction-role-panel-deleted",
263
- name: "Role panel deleted",
264
- description: "A server manager deleted a Reaction Roles panel.",
265
- defaultEnabled: true,
266
- },
267
- {
268
- id: "reaction-role-panel-updated",
269
- name: "Role panel updated",
270
- description: "A server manager updated a Reaction Roles panel.",
271
- defaultEnabled: true,
272
- },
273
- {
274
- id: "reaction-role-panel-reposted",
275
- name: "Role panel reposted",
276
- description: "A server manager published a replacement Reaction Roles message.",
277
- defaultEnabled: true,
278
- },
279
- {
280
- id: "reaction-role-panel-repaired",
281
- name: "Role panel repaired",
282
- description: "A server manager repaired a Reaction Roles panel.",
283
- defaultEnabled: true,
284
- },
285
- {
286
- id: "reaction-role-member-changed",
287
- name: "Member role changed",
288
- description: "A member added or removed an approved role through a panel.",
289
- defaultEnabled: false,
171
+ }), "lifecycle").then(() => undefined),
290
172
  },
291
173
  ],
292
174
  },
293
- privacy: {
294
- providerVersion: 1,
295
- create: (executor) => createPrivacyProvider({
296
- id: MODULE_ID,
297
- declaration: getModulePrivacyDeclaration(manifest),
298
- executor,
299
- }),
300
- },
175
+ logging: reactionRoleLogging,
176
+ privacy: createReactionRolePrivacy(manifest),
301
177
  managedResources: [managedPanels],
302
- async start(context) {
303
- try {
304
- const cleanup = await panels.recoverMessageCleanup(context.services);
305
- const count = await panels.warm(context.services);
306
- const degraded = cache.saturated() || cleanup.failed > 0 || cleanup.abandoned > 0;
307
- healthStatus = degraded ? "degraded" : "healthy";
308
- healthMessage = cache.saturated()
309
- ? `Reaction Roles loaded the bounded maximum of ${count} active panels`
310
- : cleanup.failed > 0 || cleanup.abandoned > 0
311
- ? `Reaction Roles could not clean up ${cleanup.failed + cleanup.abandoned} old panel message${cleanup.failed + cleanup.abandoned === 1 ? "" : "s"}.`
312
- : `Reaction Roles loaded ${count} active panels`;
313
- context.logger.info({
314
- event: "reactionroles.started",
315
- activePanelCount: count,
316
- cleanupCompleted: cleanup.completed,
317
- cleanupFailed: cleanup.failed,
318
- cleanupAbandoned: cleanup.abandoned,
319
- }, "Reaction Roles started");
320
- }
321
- catch (error) {
322
- healthStatus = "degraded";
323
- healthMessage = "Active role panels could not be preloaded";
324
- context.logger.warn({
325
- event: "reactionroles.cache_load_failed",
326
- errorName: error instanceof Error ? error.name : "Error",
327
- }, "Reaction Roles started without its active cache");
328
- }
329
- },
330
- stop(context) {
331
- cache.clear();
332
- memberLocks.clear();
333
- context.logger.info({ event: "reactionroles.stopped" }, "Reaction Roles stopped");
334
- return Promise.resolve();
335
- },
336
- health: () => ({ status: healthStatus, message: healthMessage }),
337
- });
338
- async function continueDraft(context) {
339
- const guildId = requireGuild(context);
340
- const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
341
- if (!session)
342
- return replyExpired(context);
343
- const draft = parseSessionDraft(session.state.draft);
344
- if (draft) {
345
- await context.reply(builderResponse(draft, context.services));
346
- return;
347
- }
348
- await context.showModal(basicModal(defaultsFromSessionState(session.state)));
349
- }
350
- async function replaceDraft(context) {
351
- const guildId = requireGuild(context);
352
- const sessions = sessionService(context.services);
353
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
354
- if (!session)
355
- return replyExpired(context);
356
- const replacement = managedObject(session.state.pendingReplacement)
357
- ? session.state.pendingReplacement
358
- : {
359
- mode: "create",
360
- selectionMode: "multiple",
361
- persistentReactions: true,
362
- };
363
- await sessions.create({
364
- guildId,
365
- userId: context.userId,
366
- purpose: PANEL_PURPOSE,
367
- state: { ...replacement },
368
- expiresAt: expiresAt(),
369
- replace: true,
370
- });
371
- await context.showModal(basicModal(defaultsFromSessionState(replacement)));
372
- }
373
- async function keepDraft(context) {
374
- const guildId = requireGuild(context);
375
- const sessions = sessionService(context.services);
376
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
377
- if (!session)
378
- return replyExpired(context);
379
- const state = { ...session.state };
380
- delete state.pendingReplacement;
381
- await sessions.update({
382
- sessionId: session.sessionId,
383
- state,
384
- expectedVersion: session.version,
385
- expiresAt: expiresAt(),
386
- });
387
- await context.reply({
388
- ephemeral: true,
389
- componentsV2: {
390
- text: [{ content: "Your existing Reaction Roles draft was kept." }],
391
- },
392
- });
393
- }
394
- async function saveBasicDraft(context) {
395
- const guildId = requireGuild(context);
396
- const sessions = sessionService(context.services);
397
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
398
- if (!session)
399
- return replyExpired(context);
400
- const prior = parseSessionDraft(session.state.draft);
401
- const controlType = requiredControl(context.getStringSelectValues("control"));
402
- const selectionMode = session.state.selectionMode === "one_only"
403
- ? "one_only"
404
- : (prior?.selectionMode ?? "multiple");
405
- const persistentReactions = controlType === "reactions" && selectionMode === "one_only"
406
- ? false
407
- : (prior?.persistentReactions ??
408
- session.state.persistentReactions !== false);
409
- const draft = {
410
- channelId: context.getSelectedChannelIds("channel")[0] ?? "",
411
- title: context.getField("title"),
412
- messageBody: context.getField("message"),
413
- messageStyle: requiredStyle(context.getStringSelectValues("style")),
414
- controlType,
415
- selectionMode,
416
- persistentReactions,
417
- ...(prior?.embedColour === undefined
418
- ? { embedColour: 0x7c3aed }
419
- : { embedColour: prior.embedColour }),
420
- ...(prior?.dropdownPlaceholder
421
- ? { dropdownPlaceholder: prior.dropdownPlaceholder }
422
- : {}),
423
- entries: prior?.entries ?? [],
424
- };
425
- await sessions.update({
426
- sessionId: session.sessionId,
427
- state: { ...session.state, draft: serializeDraft(draft) },
428
- expectedVersion: session.version,
429
- expiresAt: expiresAt(),
430
- });
431
- await context.reply(builderResponse(draft, context.services));
432
- }
433
- async function openEntryModal(context) {
434
- const guildId = requireGuild(context);
435
- const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
436
- if (!session)
437
- return replyExpired(context);
438
- const draft = parseSessionDraft(session.state.draft);
439
- if (!draft)
440
- return replyExpired(context);
441
- if (draft.entries.length >= PANEL_LIMITS[draft.controlType]) {
442
- await context.reply({
443
- ephemeral: true,
444
- componentsV2: {
445
- text: [{ content: "This panel already has the maximum role entries." }],
446
- },
447
- });
448
- return;
449
- }
450
- await context.showModal(entryModal());
451
- }
452
- async function openEditEntryModal(context) {
453
- const guildId = requireGuild(context);
454
- const sessions = sessionService(context.services);
455
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
456
- if (!session)
457
- return replyExpired(context);
458
- const draft = parseSessionDraft(session.state.draft);
459
- const entryId = context.selectedValues[0];
460
- const entry = draft?.entries.find((candidate) => candidate.entryId === entryId);
461
- if (!draft || !entry || context.selectedValues.length !== 1)
462
- return replyExpired(context);
463
- await sessions.update({
464
- sessionId: session.sessionId,
465
- state: { ...session.state, editingEntryId: entry.entryId },
466
- expectedVersion: session.version,
467
- expiresAt: expiresAt(),
468
- });
469
- await context.showModal(entryModal(entry, true));
470
- }
471
- async function saveEntry(context) {
472
- const guildId = requireGuild(context);
473
- const sessions = sessionService(context.services);
474
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
475
- if (!session)
476
- return replyExpired(context);
477
- const draft = parseSessionDraft(session.state.draft);
478
- if (!draft)
479
- return replyExpired(context);
480
- const editingEntryId = typeof session.state.editingEntryId === "string"
481
- ? session.state.editingEntryId
482
- : null;
483
- const editingIndex = editingEntryId
484
- ? draft.entries.findIndex(({ entryId }) => entryId === editingEntryId)
485
- : -1;
486
- if (editingEntryId && editingIndex < 0)
487
- return replyExpired(context);
488
- const action = editingEntryId
489
- ? (context.getStringSelectValues("entry-action")[0] ?? "save")
490
- : "save";
491
- const entries = [...draft.entries];
492
- if (editingEntryId && action === "remove") {
493
- entries.splice(editingIndex, 1);
494
- }
495
- else if (editingEntryId && action === "move-up") {
496
- if (editingIndex > 0)
497
- [entries[editingIndex - 1], entries[editingIndex]] = [
498
- entries[editingIndex],
499
- entries[editingIndex - 1],
500
- ];
501
- }
502
- else if (editingEntryId && action === "move-down") {
503
- if (editingIndex < entries.length - 1)
504
- [entries[editingIndex], entries[editingIndex + 1]] = [
505
- entries[editingIndex + 1],
506
- entries[editingIndex],
507
- ];
508
- }
509
- else {
510
- const roleId = context.getSelectedRoleIds("role")[0] ?? "";
511
- const buttonStyle = context.getStringSelectValues("button-style")[0];
512
- const entry = {
513
- entryId: editingEntryId ?? `entry-${randomUUID().replaceAll("-", "")}`,
514
- roleId,
515
- nameplate: context.getField("nameplate"),
516
- ...(context.getField("emoji").trim()
517
- ? { emoji: context.getField("emoji") }
518
- : {}),
519
- ...(buttonStyle
520
- ? {
521
- buttonStyle: buttonStyle,
522
- }
523
- : {}),
524
- };
525
- if (editingEntryId)
526
- entries[editingIndex] = entry;
527
- else
528
- entries.push(entry);
529
- }
530
- const next = entries.length === 0
531
- ? { ...draft, entries }
532
- : validatePanelDraft({
533
- ...draft,
534
- entries,
535
- });
536
- const state = {
537
- ...session.state,
538
- draft: serializeDraft(next),
539
- };
540
- delete state.editingEntryId;
541
- await sessions.update({
542
- sessionId: session.sessionId,
543
- state,
544
- expectedVersion: session.version,
545
- expiresAt: expiresAt(),
546
- });
547
- await context.reply(builderResponse(next, context.services));
548
- }
549
- async function publishDraft(context) {
550
- const guildId = requireGuild(context);
551
- const sessions = sessionService(context.services);
552
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
553
- if (!session)
554
- return replyExpired(context);
555
- const draft = parseSessionDraft(session.state.draft);
556
- if (!draft)
557
- return replyExpired(context);
558
- const mutation = interactionMutationContext(context, guildId);
559
- const panel = session.state.mode === "edit" &&
560
- typeof session.state.panelId === "string" &&
561
- typeof session.state.expectedRevision === "number"
562
- ? await panels.update(mutation, {
563
- resourceId: session.state.panelId,
564
- expectedRevision: session.state.expectedRevision,
565
- draft,
566
- })
567
- : await panels.create(mutation, draft);
568
- await sessions.delete(session.sessionId);
569
- await context.reply({
570
- ephemeral: true,
571
- componentsV2: {
572
- text: [
573
- {
574
- content: `Reaction Roles panel **${panel.title}** is active in <#${panel.channelId}>.`,
575
- },
576
- ],
577
- },
578
- allowedUserMentionIds: [],
579
- });
580
- }
581
- async function cancelDraft(context) {
582
- const guildId = requireGuild(context);
583
- const sessions = sessionService(context.services);
584
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
585
- if (session)
586
- await sessions.delete(session.sessionId);
587
- await context.reply({
588
- ephemeral: true,
589
- componentsV2: { text: [{ content: "Nothing was changed." }] },
590
- });
591
- }
592
- async function confirmDelete(context) {
593
- const guildId = requireGuild(context);
594
- const sessions = sessionService(context.services);
595
- const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
596
- if (!session ||
597
- session.state.mode !== "delete" ||
598
- typeof session.state.panelId !== "string" ||
599
- typeof session.state.expectedRevision !== "number")
600
- return replyExpired(context);
601
- await panels.delete(interactionMutationContext(context, guildId), session.state.panelId, session.state.expectedRevision);
602
- await sessions.delete(session.sessionId);
603
- await context.reply({
604
- ephemeral: true,
605
- componentsV2: {
606
- text: [
607
- {
608
- content: "The role panel was deleted. Existing member roles were not changed.",
609
- },
610
- ],
611
- },
612
- });
613
- }
614
- async function toggleSlot(context, index) {
615
- const { guildId, panel } = await activePanelForInteraction(context);
616
- const entry = panel.entries[index];
617
- if (!entry)
618
- return stalePanelReply(context);
619
- await consumeMemberLimit(context.services, guildId, context.userId);
620
- let changed = "removed";
621
- try {
622
- await withMemberLock(guildId, panel.panelId, context.userId, async () => {
623
- const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
624
- const current = new Set(await roles.getCurrentRoleIds({
625
- guildId,
626
- memberUserId: context.userId,
627
- }));
628
- const changes = [];
629
- if (panel.selectionMode === "one_only") {
630
- const desired = current.has(entry.roleId) ? [] : [entry.roleId];
631
- const result = await roles.reconcileApprovedRoles({
632
- guildId,
633
- memberUserId: context.userId,
634
- allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
635
- desiredRoleIds: desired,
636
- moduleId: MODULE_ID,
637
- reason: `Reaction Roles panel ${panel.panelId}`,
638
- });
639
- changes.push(...result.added.map((roleId) => ({
640
- roleId,
641
- outcome: "added",
642
- })), ...result.removed.map((roleId) => ({
643
- roleId,
644
- outcome: "removed",
645
- })));
646
- changed = result.added.includes(entry.roleId) ? "added" : "removed";
647
- }
648
- else if (current.has(entry.roleId)) {
649
- await roles.removeApprovedRole({
650
- guildId,
651
- memberUserId: context.userId,
652
- roleId: entry.roleId,
653
- moduleId: MODULE_ID,
654
- reason: `Reaction Roles panel ${panel.panelId}`,
655
- });
656
- changed = "removed";
657
- changes.push({ roleId: entry.roleId, outcome: changed });
658
- }
659
- else {
660
- await roles.addApprovedRole({
661
- guildId,
662
- memberUserId: context.userId,
663
- roleId: entry.roleId,
664
- moduleId: MODULE_ID,
665
- reason: `Reaction Roles panel ${panel.panelId}`,
666
- });
667
- changed = "added";
668
- changes.push({ roleId: entry.roleId, outcome: changed });
669
- }
670
- await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, changes);
671
- });
672
- }
673
- catch {
674
- await roleFailure(context, panel);
675
- return;
676
- }
677
- await context.reply(memberOutcome(entry.nameplate, changed));
678
- }
679
- async function reconcileSelection(context) {
680
- const { guildId, panel } = await activePanelForInteraction(context);
681
- if (panel.controlType !== "dropdown")
682
- return stalePanelReply(context);
683
- await consumeMemberLimit(context.services, guildId, context.userId);
684
- const selected = new Set(context.selectedValues);
685
- const desired = panel.entries
686
- .filter(({ entryId }) => selected.has(entryId))
687
- .map(({ roleId }) => roleId);
688
- if (selected.size !== desired.length ||
689
- (panel.selectionMode === "one_only" && desired.length > 1))
690
- return stalePanelReply(context);
691
- let added = 0;
692
- let removed = 0;
693
- try {
694
- await withMemberLock(guildId, panel.panelId, context.userId, async () => {
695
- const result = await context.services
696
- .get(HELYX_SERVICE_NAMES.memberRoles)
697
- .reconcileApprovedRoles({
698
- guildId,
699
- memberUserId: context.userId,
700
- allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
701
- desiredRoleIds: desired,
702
- moduleId: MODULE_ID,
703
- reason: `Reaction Roles panel ${panel.panelId}`,
704
- });
705
- added = result.added.length;
706
- removed = result.removed.length;
707
- await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, [
708
- ...result.added.map((roleId) => ({
709
- roleId,
710
- outcome: "added",
711
- })),
712
- ...result.removed.map((roleId) => ({
713
- roleId,
714
- outcome: "removed",
715
- })),
716
- ]);
717
- });
718
- }
719
- catch {
720
- await roleFailure(context, panel);
721
- return;
722
- }
723
- await context.reply({
724
- ephemeral: true,
725
- componentsV2: {
726
- text: [
727
- {
728
- content: `Your panel roles were updated: ${added} added, ${removed} removed.`,
729
- },
730
- ],
731
- },
732
- });
733
- }
734
- async function removeAllPanelRoles(context) {
735
- const { guildId, panel } = await activePanelForInteraction(context);
736
- await consumeMemberLimit(context.services, guildId, context.userId);
737
- let removed = [];
738
- try {
739
- await withMemberLock(guildId, panel.panelId, context.userId, async () => {
740
- const result = await context.services
741
- .get(HELYX_SERVICE_NAMES.memberRoles)
742
- .reconcileApprovedRoles({
743
- guildId,
744
- memberUserId: context.userId,
745
- allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
746
- desiredRoleIds: [],
747
- moduleId: MODULE_ID,
748
- reason: `Reaction Roles panel ${panel.panelId}`,
749
- });
750
- removed = result.removed;
751
- await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, result.removed.map((roleId) => ({
752
- roleId,
753
- outcome: "removed",
754
- })));
755
- });
756
- }
757
- catch {
758
- await roleFailure(context, panel);
759
- return;
760
- }
761
- await context.reply({
762
- ephemeral: true,
763
- componentsV2: {
764
- text: [
765
- {
766
- content: removed.length === 0
767
- ? "You did not have any roles from this panel."
768
- : `${removed.length} panel ${removed.length === 1 ? "role was" : "roles were"} removed.`,
769
- },
770
- ],
771
- },
772
- });
773
- }
774
- async function handleReactionAdd(context) {
775
- if (context.reactionType !== "normal")
776
- return;
777
- const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
778
- if (!panel || panel.controlType !== "reactions")
779
- return;
780
- const entry = panel.entries.find((candidate) => candidate.emoji &&
781
- canonicalEmojiKey(candidate.emoji) === context.emojiKey);
782
- if (!entry)
783
- return;
784
- const changes = [];
785
- try {
786
- await withMemberLock(context.serverId, panel.panelId, context.userId, async () => {
787
- const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
788
- if (panel.selectionMode === "one_only") {
789
- const result = await roles.reconcileApprovedRoles({
790
- guildId: context.serverId,
791
- memberUserId: context.userId,
792
- allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
793
- desiredRoleIds: [entry.roleId],
794
- moduleId: MODULE_ID,
795
- reason: `Reaction Roles panel ${panel.panelId}`,
796
- });
797
- changes.push(...result.added.map((roleId) => ({
798
- roleId,
799
- outcome: "added",
800
- })), ...result.removed.map((roleId) => ({
801
- roleId,
802
- outcome: "removed",
803
- })));
804
- return;
805
- }
806
- const result = await roles.addApprovedRole({
807
- guildId: context.serverId,
808
- memberUserId: context.userId,
809
- roleId: entry.roleId,
810
- moduleId: MODULE_ID,
811
- reason: `Reaction Roles panel ${panel.panelId}`,
812
- });
813
- if (result.changed)
814
- changes.push({ roleId: entry.roleId, outcome: "added" });
815
- });
816
- await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, changes);
817
- }
818
- catch {
819
- await panels.markNeedsAttention(panel, context.services).catch(() => false);
820
- await context.removeAddedReaction().catch(() => undefined);
821
- return;
822
- }
823
- if (!panel.persistentReactions) {
824
- try {
825
- await context.removeAddedReaction();
826
- }
827
- catch {
828
- await panels
829
- .markNeedsAttention(panel, context.services)
830
- .catch(() => false);
831
- }
832
- }
833
- }
834
- async function handleReactionRemove(context) {
835
- if (context.reactionType !== "normal")
836
- return;
837
- const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
838
- if (!panel || panel.controlType !== "reactions" || !panel.persistentReactions)
839
- return;
840
- const entry = panel.entries.find((candidate) => candidate.emoji &&
841
- canonicalEmojiKey(candidate.emoji) === context.emojiKey);
842
- if (!entry)
843
- return;
844
- try {
845
- await withMemberLock(context.serverId, panel.panelId, context.userId, () => context.services
846
- .get(HELYX_SERVICE_NAMES.memberRoles)
847
- .removeApprovedRole({
848
- guildId: context.serverId,
849
- memberUserId: context.userId,
850
- roleId: entry.roleId,
851
- moduleId: MODULE_ID,
852
- reason: `Reaction Roles panel ${panel.panelId}`,
853
- })
854
- .then(() => undefined));
855
- await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, [{ roleId: entry.roleId, outcome: "removed" }]);
856
- }
857
- catch {
858
- await panels.markNeedsAttention(panel, context.services).catch(() => false);
859
- }
860
- }
861
- async function activePanelForInteraction(context) {
862
- const guildId = context.serverId;
863
- if (!guildId || !context.messageId)
864
- throw new Error("Server panel required");
865
- const panel = await panels.resolveActive(guildId, context.messageId, context.services);
866
- if (!panel)
867
- throw new Error("Role panel is no longer active");
868
- return { guildId, panel };
869
- }
870
- async function consumeMemberLimit(services, guildId, userId) {
871
- const result = await services
872
- .get(HELYX_SERVICE_NAMES.rateLimits)
873
- .consume({
874
- scope: "user",
875
- subjectId: userId,
876
- action: `${MODULE_ID}:${guildId}:member-role`,
877
- limit: 10,
878
- windowSeconds: 10,
879
- });
880
- if (!result.allowed)
881
- throw new Error("Role panel rate limit reached");
882
- }
883
- async function withMemberLock(guildId, panelId, userId, operation) {
884
- const key = `${guildId}:${panelId}:${userId}`;
885
- const previous = memberLocks.get(key) ?? Promise.resolve();
886
- const current = previous.catch(() => undefined).then(operation);
887
- memberLocks.set(key, current);
888
- try {
889
- await current;
890
- }
891
- finally {
892
- if (memberLocks.get(key) === current)
893
- memberLocks.delete(key);
894
- }
895
- }
896
- function basicModal(panel, editing = false) {
897
- return {
898
- customId: COMPONENT_IDS.basicModal,
899
- title: editing ? "Edit role panel" : "Create role panel",
900
- fields: [
901
- {
902
- type: "discord-channel",
903
- customId: "channel",
904
- label: "Destination channel",
905
- required: true,
906
- channelTypes: ["guild_text", "guild_announcement"],
907
- ...(panel?.channelId ? { defaultValues: [panel.channelId] } : {}),
908
- },
909
- {
910
- customId: "title",
911
- label: "Title",
912
- style: "short",
913
- required: true,
914
- minLength: 1,
915
- maxLength: 80,
916
- ...(panel?.title ? { value: panel.title } : {}),
917
- },
918
- {
919
- customId: "message",
920
- label: "Message",
921
- style: "paragraph",
922
- required: true,
923
- minLength: 1,
924
- maxLength: 1_000,
925
- ...(panel?.messageBody ? { value: panel.messageBody } : {}),
926
- },
927
- {
928
- type: "string-select",
929
- customId: "style",
930
- label: "Message style",
931
- required: true,
932
- options: [
933
- {
934
- label: "Text",
935
- value: "text",
936
- default: panel?.messageStyle !== "embed",
937
- },
938
- {
939
- label: "Embed",
940
- value: "embed",
941
- default: panel?.messageStyle === "embed",
942
- },
943
- ],
944
- },
945
- {
946
- type: "string-select",
947
- customId: "control",
948
- label: "Role controls",
949
- required: true,
950
- options: [
951
- {
952
- label: "Buttons",
953
- value: "buttons",
954
- default: !panel?.controlType || panel.controlType === "buttons",
955
- },
956
- {
957
- label: "Dropdown",
958
- value: "dropdown",
959
- default: panel?.controlType === "dropdown",
960
- },
961
- {
962
- label: "Emoji reactions",
963
- value: "reactions",
964
- default: panel?.controlType === "reactions",
965
- },
966
- ],
967
- },
968
- ],
969
- };
970
- }
971
- function defaultsFromSessionState(state) {
972
- return {
973
- ...(typeof state.channelId === "string"
974
- ? { channelId: state.channelId }
975
- : {}),
976
- ...(state.messageStyle === "text" || state.messageStyle === "embed"
977
- ? { messageStyle: state.messageStyle }
978
- : {}),
979
- ...(state.controlType === "buttons" ||
980
- state.controlType === "dropdown" ||
981
- state.controlType === "reactions"
982
- ? { controlType: state.controlType }
983
- : {}),
984
- };
985
- }
986
- function managedObject(value) {
987
- return typeof value === "object" && value !== null && !Array.isArray(value);
988
- }
989
- function entryModal(entry, editing = false) {
990
- return {
991
- customId: COMPONENT_IDS.entryModal,
992
- title: editing ? "Edit role entry" : "Add a role entry",
993
- fields: [
994
- {
995
- type: "discord-role",
996
- customId: "role",
997
- label: "Discord role",
998
- required: true,
999
- ...(entry ? { defaultValues: [entry.roleId] } : {}),
1000
- },
1001
- {
1002
- customId: "nameplate",
1003
- label: "Public label",
1004
- style: "short",
1005
- required: true,
1006
- minLength: 1,
1007
- maxLength: 80,
1008
- ...(entry ? { value: entry.nameplate } : {}),
1009
- },
1010
- {
1011
- customId: "emoji",
1012
- label: "Emoji",
1013
- description: "Optional for buttons and dropdowns; required for reactions.",
1014
- style: "short",
1015
- required: false,
1016
- minLength: 1,
1017
- maxLength: 100,
1018
- ...(entry?.emoji ? { value: entry.emoji } : {}),
1019
- placeholder: "✅ or <:updates:123456789012345678>",
1020
- },
1021
- {
1022
- type: "string-select",
1023
- customId: "button-style",
1024
- label: "Button colour",
1025
- required: false,
1026
- options: [
1027
- { label: "Grey", value: "secondary", default: true },
1028
- { label: "Blue", value: "primary" },
1029
- { label: "Green", value: "success" },
1030
- { label: "Red", value: "danger" },
1031
- ],
1032
- },
1033
- ...(editing
1034
- ? [
1035
- {
1036
- type: "string-select",
1037
- customId: "entry-action",
1038
- label: "Entry action",
1039
- required: true,
1040
- options: [
1041
- { label: "Save changes", value: "save", default: true },
1042
- { label: "Move up", value: "move-up" },
1043
- { label: "Move down", value: "move-down" },
1044
- { label: "Remove entry", value: "remove" },
1045
- ],
1046
- },
1047
- ]
1048
- : []),
1049
- ],
1050
- };
1051
- }
1052
- function builderResponse(draft, services) {
1053
- const builder = services.get(HELYX_SERVICE_NAMES.messageBuilder);
1054
- const panel = draft.entries.length > 0
1055
- ? builder.build(panelMessageDraft(draft)).response
1056
- : builder.build({
1057
- presentation: draft.messageStyle === "embed"
1058
- ? {
1059
- type: "embed",
1060
- title: draft.title,
1061
- body: draft.messageBody,
1062
- ...(draft.embedColour === undefined
1063
- ? {}
1064
- : { color: draft.embedColour }),
178
+ scheduledTasks: [
179
+ {
180
+ kind: REACTION_ROLE_CLEANUP_TASK,
181
+ version: 1,
182
+ delivery: "one-shot",
183
+ attemptLimit: 10,
184
+ validatePayload: validateCleanupPayload,
185
+ async execute(context, task) {
186
+ const payload = validateCleanupPayload(task.payload);
187
+ const execution = await panels.executeMessageCleanupWithRecovery(context.services, payload.cleanupId);
188
+ const { result } = execution;
189
+ if (result.outcome === "completed") {
190
+ if (execution.recovered)
191
+ moduleHealth.cleanupRecovered();
192
+ moduleHealth.primarySuccess("lifecycle");
1065
193
  }
1066
- : {
1067
- type: "text",
1068
- title: draft.title,
1069
- body: draft.messageBody,
1070
- accentColor: 0x7c3aed,
1071
- },
1072
- allowedUserMentionIds: [],
1073
- }).response;
1074
- return {
1075
- ...panel,
1076
- ephemeral: true,
1077
- selects: draft.entries.length === 0
1078
- ? []
1079
- : [
1080
- {
1081
- type: "string",
1082
- customId: COMPONENT_IDS.builderEntries,
1083
- placeholder: "Edit, reorder or remove a role",
1084
- minValues: 1,
1085
- maxValues: 1,
1086
- options: draft.entries.map((entry) => ({
1087
- label: entry.nameplate,
1088
- value: entry.entryId,
1089
- ...(entry.emoji
1090
- ? {
1091
- emojiIdentifier: canonicaliseEmoji(entry.emoji)
1092
- .identifier,
1093
- }
1094
- : {}),
1095
- })),
1096
- },
1097
- ],
1098
- buttons: [
1099
- {
1100
- type: "button",
1101
- customId: COMPONENT_IDS.addEntry,
1102
- label: "Add role",
1103
- style: "secondary",
1104
- disabled: draft.entries.length >= PANEL_LIMITS[draft.controlType],
1105
- },
1106
- {
1107
- type: "button",
1108
- customId: COMPONENT_IDS.publish,
1109
- label: "Publish panel",
1110
- style: "success",
1111
- disabled: draft.entries.length === 0,
1112
- },
1113
- {
1114
- type: "button",
1115
- customId: COMPONENT_IDS.cancel,
1116
- label: "Cancel",
1117
- style: "secondary",
194
+ else
195
+ moduleHealth.primaryFailure("lifecycle");
196
+ return result;
1118
197
  },
1119
- ],
1120
- allowedUserMentionIds: [],
1121
- };
1122
- }
1123
- function interactionMutationContext(context, guildId) {
1124
- return {
1125
- guildId,
1126
- actorUserId: context.userId,
1127
- source: "discord",
1128
- services: context.services,
1129
- correlationId: context.interactionId,
1130
- sendToChannel: (channelId, response) => context.sendToChannel(channelId, response),
1131
- deleteChannelMessage: (channelId, messageId) => context.deleteChannelMessage(channelId, messageId),
1132
- editChannelMessage: (channelId, messageId, response) => context.editChannelMessage(channelId, messageId, response),
1133
- addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => context.addChannelMessageReaction(channelId, messageId, emojiIdentifier),
1134
- };
1135
- }
1136
- function sessionService(services) {
1137
- return services.get(HELYX_SERVICE_NAMES.sessions);
1138
- }
1139
- function requireGuild(context) {
1140
- if (!context.serverId)
1141
- throw new Error("Use this command in a Discord server");
1142
- return context.serverId;
1143
- }
1144
- function expiresAt() {
1145
- return new Date(Date.now() + 20 * 60 * 1_000);
1146
- }
1147
- function serializeDraft(draft) {
1148
- return {
1149
- channelId: draft.channelId,
1150
- title: draft.title,
1151
- messageBody: draft.messageBody,
1152
- messageStyle: draft.messageStyle,
1153
- controlType: draft.controlType,
1154
- selectionMode: draft.selectionMode,
1155
- persistentReactions: draft.persistentReactions,
1156
- ...(draft.embedColour === undefined
1157
- ? {}
1158
- : { embedColour: draft.embedColour }),
1159
- ...(draft.dropdownPlaceholder === undefined
1160
- ? {}
1161
- : { dropdownPlaceholder: draft.dropdownPlaceholder }),
1162
- entries: draft.entries.map((entry) => ({ ...entry })),
1163
- };
1164
- }
1165
- function parseSessionDraft(value) {
1166
- if (!isObject(value) || !Array.isArray(value.entries))
1167
- return null;
1168
- try {
1169
- return validatePanelDraft({
1170
- channelId: stringValue(value.channelId),
1171
- title: stringValue(value.title),
1172
- messageBody: stringValue(value.messageBody),
1173
- messageStyle: stringValue(value.messageStyle),
1174
- controlType: stringValue(value.controlType),
1175
- selectionMode: stringValue(value.selectionMode),
1176
- persistentReactions: typeof value.persistentReactions === "boolean"
1177
- ? value.persistentReactions
1178
- : true,
1179
- ...(typeof value.embedColour === "number"
1180
- ? { embedColour: value.embedColour }
1181
- : {}),
1182
- ...(typeof value.dropdownPlaceholder === "string"
1183
- ? { dropdownPlaceholder: value.dropdownPlaceholder }
1184
- : {}),
1185
- entries: value.entries.map((entry) => {
1186
- if (!isObject(entry))
1187
- throw new Error("invalid entry");
1188
- return {
1189
- entryId: stringValue(entry.entryId),
1190
- roleId: stringValue(entry.roleId),
1191
- nameplate: stringValue(entry.nameplate),
1192
- ...(typeof entry.emoji === "string" ? { emoji: entry.emoji } : {}),
1193
- ...(typeof entry.buttonStyle === "string"
1194
- ? { buttonStyle: entry.buttonStyle }
1195
- : {}),
1196
- };
1197
- }),
1198
- }, { allowEmptyEntries: true });
1199
- }
1200
- catch {
1201
- return null;
1202
- }
1203
- }
1204
- async function replyExpired(context) {
1205
- await context.reply({
1206
- ephemeral: true,
1207
- componentsV2: {
1208
- text: [
1209
- {
1210
- content: "This role-panel builder expired. Run `/reaction-role create` or `/reaction-role edit` again.",
1211
- },
1212
- ],
1213
- },
1214
- });
1215
- }
1216
- async function stalePanelReply(context) {
1217
- await context.reply({
1218
- ephemeral: true,
1219
- componentsV2: {
1220
- text: [
1221
- {
1222
- content: "This role panel changed or is no longer active. Ask a server manager to repair it.",
1223
- },
1224
- ],
1225
- },
1226
- });
1227
- }
1228
- function memberOutcome(nameplate, changed) {
1229
- return {
1230
- ephemeral: true,
1231
- componentsV2: {
1232
- text: [
1233
- {
1234
- content: `${nameplate} was ${changed === "added" ? "added to" : "removed from"} your roles.`,
1235
- },
1236
- ],
1237
- },
1238
- allowedUserMentionIds: [],
1239
- };
1240
- }
1241
- async function roleFailure(context, panel) {
1242
- await panels.markNeedsAttention(panel, context.services).catch(() => false);
1243
- await context.reply({
1244
- ephemeral: true,
1245
- componentsV2: {
1246
- text: [
1247
- {
1248
- content: "Helyx cannot manage that role right now. A server administrator needs to check the Reaction Roles panel.",
1249
- },
1250
- ],
1251
198
  },
1252
- allowedUserMentionIds: [],
1253
- });
1254
- }
1255
- async function emitMemberChanges(services, guildId, memberUserId, panelId, changes) {
1256
- if (changes.length === 0 || !services.has(HELYX_SERVICE_NAMES.moduleLogging))
1257
- return;
1258
- const logging = services.get(HELYX_SERVICE_NAMES.moduleLogging);
1259
- await Promise.all(changes.slice(0, 25).map(({ roleId, outcome }) => logging
1260
- .emit({
1261
- guildId,
1262
- moduleId: MODULE_ID,
1263
- eventId: "reaction-role-member-changed",
1264
- summary: "A member changed an approved Reaction Roles role.",
1265
- details: [
1266
- { label: "Member ID", value: memberUserId },
1267
- { label: "Panel", value: panelId },
1268
- { label: "Role ID", value: roleId },
1269
- { label: "Outcome", value: outcome },
1270
- ],
1271
- })
1272
- .catch(() => ({ delivered: false }))));
1273
- }
1274
- function requiredStyle(values) {
1275
- const value = values[0];
1276
- if (value !== "text" && value !== "embed")
1277
- throw new Error("Choose Text or Embed");
1278
- return value;
1279
- }
1280
- function requiredControl(values) {
1281
- const value = values[0];
1282
- if (value !== "buttons" && value !== "dropdown" && value !== "reactions")
1283
- throw new Error("Choose valid role controls");
1284
- return value;
1285
- }
1286
- function canonicalEmojiKey(value) {
1287
- const custom = /^([A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(value);
1288
- return custom ? `custom:${custom[2]}` : `unicode:${value.normalize("NFKC")}`;
1289
- }
1290
- function isObject(value) {
1291
- return typeof value === "object" && value !== null && !Array.isArray(value);
1292
- }
1293
- function stringValue(value) {
1294
- if (typeof value !== "string")
1295
- throw new Error("invalid string");
1296
- return value;
1297
- }
199
+ ],
200
+ start: startReactionRoles,
201
+ stop: stopReactionRoles,
202
+ health: () => moduleHealth.health(),
203
+ });
1298
204
  export default reactionRolesModule;
205
+ function validateCleanupPayload(input) {
206
+ if (!input || typeof input !== "object" || Array.isArray(input))
207
+ invalid();
208
+ const value = input;
209
+ if (Object.keys(value).length !== 1 ||
210
+ typeof value.cleanupId !== "string" ||
211
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value.cleanupId))
212
+ invalid();
213
+ return { cleanupId: value.cleanupId };
214
+ }
215
+ function invalid() {
216
+ throw new Error("Reaction Roles cleanup payload is invalid.");
217
+ }