@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.
package/dist/index.js ADDED
@@ -0,0 +1,1618 @@
1
+ import { parseModuleManifest } from "@helyx/module-manifest";
2
+ import { createPrivacyProvider, DashboardActionValidationError, defineModule, getModulePrivacyDeclaration, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
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";
8
+ import { reactionRoleRecords } from "./records.js";
9
+ 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
+ export const reactionRolesModule = defineModule({
82
+ manifest,
83
+ defaultPermissions: {
84
+ module: managerPolicy,
85
+ commands: {
86
+ "reactionroles.create": managerPolicy,
87
+ "reactionroles.manage": managerPolicy,
88
+ "reactionroles.delete": managerPolicy,
89
+ },
90
+ },
91
+ records: reactionRoleRecords,
92
+ interactions: {
93
+ commands: [
94
+ {
95
+ name: "reaction-role",
96
+ description: "Create and manage Helyx role panels.",
97
+ registration: "global",
98
+ contexts: ["guild"],
99
+ integrationTypes: ["guild_install"],
100
+ subcommands: [
101
+ {
102
+ name: "create",
103
+ description: "Open the guided Reaction Roles builder.",
104
+ permissionId: "reactionroles.create",
105
+ options: [
106
+ {
107
+ type: "channel",
108
+ name: "channel",
109
+ description: "Prefill the destination channel.",
110
+ channelTypes: ["guild_text", "guild_announcement"],
111
+ },
112
+ {
113
+ type: "string",
114
+ name: "style",
115
+ description: "Prefill the public message style.",
116
+ choices: [
117
+ { name: "Text", value: "text" },
118
+ { name: "Embed", value: "embed" },
119
+ ],
120
+ },
121
+ {
122
+ type: "string",
123
+ name: "control",
124
+ description: "Prefill how members choose roles.",
125
+ choices: [
126
+ { name: "Buttons", value: "buttons" },
127
+ { name: "Dropdown", value: "dropdown" },
128
+ { name: "Emoji reactions", value: "reactions" },
129
+ ],
130
+ },
131
+ {
132
+ type: "boolean",
133
+ name: "one-role-only",
134
+ description: "Allow each member to hold only one role from this panel.",
135
+ },
136
+ {
137
+ type: "boolean",
138
+ name: "persistent-reactions",
139
+ description: "Keep member reactions visible and synchronised with roles.",
140
+ },
141
+ ],
142
+ rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
143
+ execute: startCreate,
144
+ },
145
+ {
146
+ name: "edit",
147
+ description: "Edit an existing role panel.",
148
+ permissionId: "reactionroles.manage",
149
+ options: [
150
+ referenceOption(),
151
+ {
152
+ type: "boolean",
153
+ name: "persistent-reactions",
154
+ description: "Keep member reactions visible and synchronised with roles.",
155
+ },
156
+ ],
157
+ autocomplete: autocompletePanelReference,
158
+ execute: startEdit,
159
+ },
160
+ {
161
+ name: "list",
162
+ description: "List the active and degraded role panels.",
163
+ permissionId: "reactionroles.manage",
164
+ execute: listPanels,
165
+ },
166
+ {
167
+ name: "repair",
168
+ description: "Revalidate and repair a role panel.",
169
+ permissionId: "reactionroles.manage",
170
+ options: [referenceOption()],
171
+ autocomplete: autocompletePanelReference,
172
+ execute: repairPanel,
173
+ },
174
+ {
175
+ name: "delete",
176
+ description: "Delete a role panel without changing member roles.",
177
+ permissionId: "reactionroles.delete",
178
+ options: [referenceOption()],
179
+ autocomplete: autocompletePanelReference,
180
+ execute: prepareDelete,
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ buttons: [
186
+ {
187
+ customId: COMPONENT_IDS.continueDraft,
188
+ execute: continueDraft,
189
+ },
190
+ {
191
+ customId: COMPONENT_IDS.replaceDraft,
192
+ execute: replaceDraft,
193
+ },
194
+ {
195
+ customId: COMPONENT_IDS.keepDraft,
196
+ execute: keepDraft,
197
+ },
198
+ {
199
+ customId: COMPONENT_IDS.addEntry,
200
+ execute: openEntryModal,
201
+ },
202
+ {
203
+ customId: COMPONENT_IDS.publish,
204
+ acknowledgement: "deferred-ephemeral",
205
+ execute: publishDraft,
206
+ },
207
+ {
208
+ customId: COMPONENT_IDS.cancel,
209
+ execute: cancelDraft,
210
+ },
211
+ {
212
+ customId: COMPONENT_IDS.deleteConfirm,
213
+ acknowledgement: "deferred-ephemeral",
214
+ execute: confirmDelete,
215
+ },
216
+ ...COMPONENT_IDS.slots.map((customId, index) => ({
217
+ customId,
218
+ acknowledgement: "deferred-ephemeral",
219
+ execute: (context) => toggleSlot(context, index),
220
+ })),
221
+ {
222
+ customId: COMPONENT_IDS.removeAll,
223
+ acknowledgement: "deferred-ephemeral",
224
+ execute: removeAllPanelRoles,
225
+ },
226
+ ],
227
+ modals: [
228
+ { customId: COMPONENT_IDS.basicModal, execute: saveBasicDraft },
229
+ { customId: COMPONENT_IDS.entryModal, execute: saveEntry },
230
+ ],
231
+ selects: [
232
+ {
233
+ customId: COMPONENT_IDS.builderEntries,
234
+ execute: openEditEntryModal,
235
+ },
236
+ {
237
+ customId: COMPONENT_IDS.select,
238
+ acknowledgement: "deferred-ephemeral",
239
+ execute: reconcileSelection,
240
+ },
241
+ ],
242
+ },
243
+ events: {
244
+ messageReactionAdd: [
245
+ {
246
+ type: "discord.message-reaction-add",
247
+ execute: handleReactionAdd,
248
+ },
249
+ ],
250
+ messageReactionRemove: [
251
+ {
252
+ type: "discord.message-reaction-remove",
253
+ execute: handleReactionRemove,
254
+ },
255
+ ],
256
+ messageDelete: [
257
+ {
258
+ type: "discord.message-delete",
259
+ execute: (context) => panels.markMessageMissing({
260
+ guildId: context.serverId,
261
+ messageId: context.messageId,
262
+ services: context.services,
263
+ }),
264
+ },
265
+ ],
266
+ guildRoleDelete: [
267
+ {
268
+ type: "discord.guild-role-delete",
269
+ execute: (context) => panels
270
+ .handleRoleLifecycle({
271
+ guildId: context.serverId,
272
+ roleId: context.roleId,
273
+ deleted: true,
274
+ services: context.services,
275
+ })
276
+ .then(() => undefined),
277
+ },
278
+ ],
279
+ guildRoleUpdate: [
280
+ {
281
+ type: "discord.guild-role-update",
282
+ execute: (context) => panels
283
+ .handleRoleLifecycle({
284
+ guildId: context.serverId,
285
+ roleId: context.roleId,
286
+ deleted: false,
287
+ services: context.services,
288
+ })
289
+ .then(() => undefined),
290
+ },
291
+ ],
292
+ channelDelete: [
293
+ {
294
+ type: "discord.channel-delete",
295
+ execute: (context) => panels
296
+ .handleChannelLifecycle({
297
+ guildId: context.serverId,
298
+ channelId: context.channelId,
299
+ deleted: true,
300
+ services: context.services,
301
+ })
302
+ .then(() => undefined),
303
+ },
304
+ ],
305
+ channelUpdate: [
306
+ {
307
+ type: "discord.channel-update",
308
+ execute: (context) => panels
309
+ .handleChannelLifecycle({
310
+ guildId: context.serverId,
311
+ channelId: context.channelId,
312
+ deleted: false,
313
+ services: context.services,
314
+ })
315
+ .then(() => undefined),
316
+ },
317
+ ],
318
+ guildEmojiDelete: [
319
+ {
320
+ type: "discord.guild-emoji-delete",
321
+ execute: (context) => panels
322
+ .handleEmojiLifecycle({
323
+ guildId: context.serverId,
324
+ emojiId: context.emojiId,
325
+ deleted: true,
326
+ services: context.services,
327
+ })
328
+ .then(() => undefined),
329
+ },
330
+ ],
331
+ guildEmojiUpdate: [
332
+ {
333
+ type: "discord.guild-emoji-update",
334
+ execute: (context) => panels
335
+ .handleEmojiLifecycle({
336
+ guildId: context.serverId,
337
+ emojiId: context.emojiId,
338
+ deleted: false,
339
+ services: context.services,
340
+ })
341
+ .then(() => undefined),
342
+ },
343
+ ],
344
+ },
345
+ logging: {
346
+ events: [
347
+ {
348
+ id: "reaction-role-panel-created",
349
+ name: "Role panel created",
350
+ description: "A server manager created a Reaction Roles panel.",
351
+ defaultEnabled: true,
352
+ },
353
+ {
354
+ id: "reaction-role-panel-deleted",
355
+ name: "Role panel deleted",
356
+ description: "A server manager deleted a Reaction Roles panel.",
357
+ defaultEnabled: true,
358
+ },
359
+ {
360
+ id: "reaction-role-panel-updated",
361
+ name: "Role panel updated",
362
+ description: "A server manager updated a Reaction Roles panel.",
363
+ defaultEnabled: true,
364
+ },
365
+ {
366
+ id: "reaction-role-panel-reposted",
367
+ name: "Role panel reposted",
368
+ description: "A server manager published a replacement Reaction Roles message.",
369
+ defaultEnabled: true,
370
+ },
371
+ {
372
+ id: "reaction-role-panel-repaired",
373
+ name: "Role panel repaired",
374
+ description: "A server manager repaired a Reaction Roles panel.",
375
+ defaultEnabled: true,
376
+ },
377
+ {
378
+ id: "reaction-role-member-changed",
379
+ name: "Member role changed",
380
+ description: "A member added or removed an approved role through a panel.",
381
+ defaultEnabled: false,
382
+ },
383
+ ],
384
+ },
385
+ privacy: {
386
+ providerVersion: 1,
387
+ create: (executor) => createPrivacyProvider({
388
+ id: MODULE_ID,
389
+ declaration: getModulePrivacyDeclaration(manifest),
390
+ executor,
391
+ }),
392
+ },
393
+ managedResources: [managedPanels],
394
+ async start(context) {
395
+ try {
396
+ const cleanup = await panels.recoverMessageCleanup(context.services);
397
+ const count = await panels.warm(context.services);
398
+ const degraded = cache.saturated() || cleanup.failed > 0 || cleanup.abandoned > 0;
399
+ healthStatus = degraded ? "degraded" : "healthy";
400
+ healthMessage = cache.saturated()
401
+ ? `Reaction Roles loaded the bounded maximum of ${count} active panels`
402
+ : cleanup.failed > 0 || cleanup.abandoned > 0
403
+ ? `Reaction Roles loaded ${count} active panels; ${cleanup.failed + cleanup.abandoned} message cleanups need attention`
404
+ : `Reaction Roles loaded ${count} active panels`;
405
+ context.logger.info({
406
+ event: "reactionroles.started",
407
+ activePanelCount: count,
408
+ cleanupCompleted: cleanup.completed,
409
+ cleanupFailed: cleanup.failed,
410
+ cleanupAbandoned: cleanup.abandoned,
411
+ }, "Reaction Roles started");
412
+ }
413
+ catch (error) {
414
+ healthStatus = "degraded";
415
+ healthMessage = "Active role panels could not be preloaded";
416
+ context.logger.warn({
417
+ event: "reactionroles.cache_load_failed",
418
+ errorName: error instanceof Error ? error.name : "Error",
419
+ }, "Reaction Roles started without its active cache");
420
+ }
421
+ },
422
+ stop(context) {
423
+ cache.clear();
424
+ memberLocks.clear();
425
+ context.logger.info({ event: "reactionroles.stopped" }, "Reaction Roles stopped");
426
+ return Promise.resolve();
427
+ },
428
+ health: () => ({ status: healthStatus, message: healthMessage }),
429
+ });
430
+ async function startCreate(context) {
431
+ const guildId = requireGuild(context);
432
+ const channel = context.getChannelOption("channel");
433
+ const style = context.getStringOption("style");
434
+ const control = context.getStringOption("control");
435
+ const sessions = sessionService(context.services);
436
+ const nextState = {
437
+ mode: "create",
438
+ selectionMode: context.getBooleanOption("one-role-only") === true
439
+ ? "one_only"
440
+ : "multiple",
441
+ persistentReactions: context.getBooleanOption("persistent-reactions") !== false,
442
+ ...(channel ? { channelId: channel.id } : {}),
443
+ ...(style ? { messageStyle: requiredStyle([style]) } : {}),
444
+ ...(control ? { controlType: requiredControl([control]) } : {}),
445
+ };
446
+ const existing = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
447
+ if (existing) {
448
+ await sessions.update({
449
+ sessionId: existing.sessionId,
450
+ state: { ...existing.state, pendingReplacement: nextState },
451
+ expectedVersion: existing.version,
452
+ expiresAt: expiresAt(),
453
+ });
454
+ await context.reply(existingDraftResponse());
455
+ return;
456
+ }
457
+ await sessions.create({
458
+ guildId,
459
+ userId: context.userId,
460
+ purpose: PANEL_PURPOSE,
461
+ state: nextState,
462
+ expiresAt: expiresAt(),
463
+ replace: true,
464
+ });
465
+ await context.showModal(basicModal(defaultsFromSessionState(nextState)));
466
+ }
467
+ async function continueDraft(context) {
468
+ const guildId = requireGuild(context);
469
+ const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
470
+ if (!session)
471
+ return replyExpired(context);
472
+ const draft = parseSessionDraft(session.state.draft);
473
+ if (draft) {
474
+ await context.reply(builderResponse(draft, context.services));
475
+ return;
476
+ }
477
+ await context.showModal(basicModal(defaultsFromSessionState(session.state)));
478
+ }
479
+ async function replaceDraft(context) {
480
+ const guildId = requireGuild(context);
481
+ const sessions = sessionService(context.services);
482
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
483
+ if (!session)
484
+ return replyExpired(context);
485
+ const replacement = managedObject(session.state.pendingReplacement)
486
+ ? session.state.pendingReplacement
487
+ : {
488
+ mode: "create",
489
+ selectionMode: "multiple",
490
+ persistentReactions: true,
491
+ };
492
+ await sessions.create({
493
+ guildId,
494
+ userId: context.userId,
495
+ purpose: PANEL_PURPOSE,
496
+ state: { ...replacement },
497
+ expiresAt: expiresAt(),
498
+ replace: true,
499
+ });
500
+ await context.showModal(basicModal(defaultsFromSessionState(replacement)));
501
+ }
502
+ async function keepDraft(context) {
503
+ const guildId = requireGuild(context);
504
+ const sessions = sessionService(context.services);
505
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
506
+ if (!session)
507
+ return replyExpired(context);
508
+ const state = { ...session.state };
509
+ delete state.pendingReplacement;
510
+ await sessions.update({
511
+ sessionId: session.sessionId,
512
+ state,
513
+ expectedVersion: session.version,
514
+ expiresAt: expiresAt(),
515
+ });
516
+ await context.reply({
517
+ ephemeral: true,
518
+ componentsV2: {
519
+ text: [{ content: "Your existing Reaction Roles draft was kept." }],
520
+ },
521
+ });
522
+ }
523
+ async function startEdit(context) {
524
+ const guildId = requireGuild(context);
525
+ const panel = await findPanelReference(context.services, guildId, context.getStringOption("reference"));
526
+ const persistentReactions = context.getBooleanOption("persistent-reactions");
527
+ const draft = persistentReactions === null
528
+ ? panel
529
+ : validatePanelDraft({ ...panel, persistentReactions });
530
+ await sessionService(context.services).create({
531
+ guildId,
532
+ userId: context.userId,
533
+ purpose: PANEL_PURPOSE,
534
+ state: {
535
+ mode: "edit",
536
+ panelId: panel.panelId,
537
+ expectedRevision: panel.revision,
538
+ draft: serializeDraft(draft),
539
+ },
540
+ expiresAt: expiresAt(),
541
+ replace: true,
542
+ });
543
+ await context.showModal(basicModal(draft, true));
544
+ }
545
+ async function listPanels(context) {
546
+ const guildId = requireGuild(context);
547
+ const result = await panels.repository(context.services).list({
548
+ guildId,
549
+ limit: 10,
550
+ });
551
+ await context.reply({
552
+ ephemeral: true,
553
+ componentsV2: {
554
+ text: [
555
+ {
556
+ content: result.panels.length === 0
557
+ ? "No Reaction Roles panels have been created."
558
+ : result.panels
559
+ .map((panel) => `**${panel.title}** · \`${panel.panelId}\` · ${panel.status} · revision ${panel.revision}`)
560
+ .join("\n"),
561
+ },
562
+ ],
563
+ },
564
+ allowedUserMentionIds: [],
565
+ });
566
+ }
567
+ async function repairPanel(context) {
568
+ const guildId = requireGuild(context);
569
+ await context.deferReply?.(true);
570
+ const panel = await findPanelReference(context.services, guildId, context.getStringOption("reference"));
571
+ const repaired = await panels.repair(interactionMutationContext(context, guildId), panel.panelId, panel.revision, false);
572
+ await context.reply({
573
+ ephemeral: true,
574
+ componentsV2: {
575
+ text: [
576
+ {
577
+ content: `Role panel **${repaired.title}** is active at revision ${repaired.revision}.`,
578
+ },
579
+ ],
580
+ },
581
+ allowedUserMentionIds: [],
582
+ });
583
+ }
584
+ async function prepareDelete(context) {
585
+ const guildId = requireGuild(context);
586
+ const panel = await findPanelReference(context.services, guildId, context.getStringOption("reference"));
587
+ await sessionService(context.services).create({
588
+ guildId,
589
+ userId: context.userId,
590
+ purpose: PANEL_PURPOSE,
591
+ state: {
592
+ mode: "delete",
593
+ panelId: panel.panelId,
594
+ expectedRevision: panel.revision,
595
+ },
596
+ expiresAt: expiresAt(),
597
+ replace: true,
598
+ });
599
+ await context.reply({
600
+ ephemeral: true,
601
+ componentsV2: {
602
+ text: [
603
+ {
604
+ content: `Delete **${panel.title}**? Helyx will try to remove its panel message. Existing member roles will not change.`,
605
+ },
606
+ ],
607
+ },
608
+ buttons: [
609
+ {
610
+ type: "button",
611
+ customId: COMPONENT_IDS.deleteConfirm,
612
+ label: "Delete role panel",
613
+ style: "danger",
614
+ },
615
+ {
616
+ type: "button",
617
+ customId: COMPONENT_IDS.cancel,
618
+ label: "Keep role panel",
619
+ style: "secondary",
620
+ },
621
+ ],
622
+ allowedUserMentionIds: [],
623
+ });
624
+ }
625
+ async function saveBasicDraft(context) {
626
+ const guildId = requireGuild(context);
627
+ const sessions = sessionService(context.services);
628
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
629
+ if (!session)
630
+ return replyExpired(context);
631
+ const prior = parseSessionDraft(session.state.draft);
632
+ const controlType = requiredControl(context.getStringSelectValues("control"));
633
+ const selectionMode = session.state.selectionMode === "one_only"
634
+ ? "one_only"
635
+ : (prior?.selectionMode ?? "multiple");
636
+ const persistentReactions = controlType === "reactions" && selectionMode === "one_only"
637
+ ? false
638
+ : (prior?.persistentReactions ??
639
+ session.state.persistentReactions !== false);
640
+ const draft = {
641
+ channelId: context.getSelectedChannelIds("channel")[0] ?? "",
642
+ title: context.getField("title"),
643
+ messageBody: context.getField("message"),
644
+ messageStyle: requiredStyle(context.getStringSelectValues("style")),
645
+ controlType,
646
+ selectionMode,
647
+ persistentReactions,
648
+ ...(prior?.embedColour === undefined
649
+ ? { embedColour: 0x7c3aed }
650
+ : { embedColour: prior.embedColour }),
651
+ ...(prior?.dropdownPlaceholder
652
+ ? { dropdownPlaceholder: prior.dropdownPlaceholder }
653
+ : {}),
654
+ entries: prior?.entries ?? [],
655
+ };
656
+ await sessions.update({
657
+ sessionId: session.sessionId,
658
+ state: { ...session.state, draft: serializeDraft(draft) },
659
+ expectedVersion: session.version,
660
+ expiresAt: expiresAt(),
661
+ });
662
+ await context.reply(builderResponse(draft, context.services));
663
+ }
664
+ async function openEntryModal(context) {
665
+ const guildId = requireGuild(context);
666
+ const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
667
+ if (!session)
668
+ return replyExpired(context);
669
+ const draft = parseSessionDraft(session.state.draft);
670
+ if (!draft)
671
+ return replyExpired(context);
672
+ if (draft.entries.length >= PANEL_LIMITS[draft.controlType]) {
673
+ await context.reply({
674
+ ephemeral: true,
675
+ componentsV2: {
676
+ text: [{ content: "This panel already has the maximum role entries." }],
677
+ },
678
+ });
679
+ return;
680
+ }
681
+ await context.showModal(entryModal());
682
+ }
683
+ async function openEditEntryModal(context) {
684
+ const guildId = requireGuild(context);
685
+ const sessions = sessionService(context.services);
686
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
687
+ if (!session)
688
+ return replyExpired(context);
689
+ const draft = parseSessionDraft(session.state.draft);
690
+ const entryId = context.selectedValues[0];
691
+ const entry = draft?.entries.find((candidate) => candidate.entryId === entryId);
692
+ if (!draft || !entry || context.selectedValues.length !== 1)
693
+ return replyExpired(context);
694
+ await sessions.update({
695
+ sessionId: session.sessionId,
696
+ state: { ...session.state, editingEntryId: entry.entryId },
697
+ expectedVersion: session.version,
698
+ expiresAt: expiresAt(),
699
+ });
700
+ await context.showModal(entryModal(entry, true));
701
+ }
702
+ async function saveEntry(context) {
703
+ const guildId = requireGuild(context);
704
+ const sessions = sessionService(context.services);
705
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
706
+ if (!session)
707
+ return replyExpired(context);
708
+ const draft = parseSessionDraft(session.state.draft);
709
+ if (!draft)
710
+ return replyExpired(context);
711
+ const editingEntryId = typeof session.state.editingEntryId === "string"
712
+ ? session.state.editingEntryId
713
+ : null;
714
+ const editingIndex = editingEntryId
715
+ ? draft.entries.findIndex(({ entryId }) => entryId === editingEntryId)
716
+ : -1;
717
+ if (editingEntryId && editingIndex < 0)
718
+ return replyExpired(context);
719
+ const action = editingEntryId
720
+ ? (context.getStringSelectValues("entry-action")[0] ?? "save")
721
+ : "save";
722
+ const entries = [...draft.entries];
723
+ if (editingEntryId && action === "remove") {
724
+ entries.splice(editingIndex, 1);
725
+ }
726
+ else if (editingEntryId && action === "move-up") {
727
+ if (editingIndex > 0)
728
+ [entries[editingIndex - 1], entries[editingIndex]] = [
729
+ entries[editingIndex],
730
+ entries[editingIndex - 1],
731
+ ];
732
+ }
733
+ else if (editingEntryId && action === "move-down") {
734
+ if (editingIndex < entries.length - 1)
735
+ [entries[editingIndex], entries[editingIndex + 1]] = [
736
+ entries[editingIndex + 1],
737
+ entries[editingIndex],
738
+ ];
739
+ }
740
+ else {
741
+ const roleId = context.getSelectedRoleIds("role")[0] ?? "";
742
+ const buttonStyle = context.getStringSelectValues("button-style")[0];
743
+ const entry = {
744
+ entryId: editingEntryId ?? `entry-${randomUUID().replaceAll("-", "")}`,
745
+ roleId,
746
+ nameplate: context.getField("nameplate"),
747
+ ...(context.getField("emoji").trim()
748
+ ? { emoji: context.getField("emoji") }
749
+ : {}),
750
+ ...(buttonStyle
751
+ ? {
752
+ buttonStyle: buttonStyle,
753
+ }
754
+ : {}),
755
+ };
756
+ if (editingEntryId)
757
+ entries[editingIndex] = entry;
758
+ else
759
+ entries.push(entry);
760
+ }
761
+ const next = entries.length === 0
762
+ ? { ...draft, entries }
763
+ : validatePanelDraft({
764
+ ...draft,
765
+ entries,
766
+ });
767
+ const state = {
768
+ ...session.state,
769
+ draft: serializeDraft(next),
770
+ };
771
+ delete state.editingEntryId;
772
+ await sessions.update({
773
+ sessionId: session.sessionId,
774
+ state,
775
+ expectedVersion: session.version,
776
+ expiresAt: expiresAt(),
777
+ });
778
+ await context.reply(builderResponse(next, context.services));
779
+ }
780
+ async function publishDraft(context) {
781
+ const guildId = requireGuild(context);
782
+ const sessions = sessionService(context.services);
783
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
784
+ if (!session)
785
+ return replyExpired(context);
786
+ const draft = parseSessionDraft(session.state.draft);
787
+ if (!draft)
788
+ return replyExpired(context);
789
+ const mutation = interactionMutationContext(context, guildId);
790
+ const panel = session.state.mode === "edit" &&
791
+ typeof session.state.panelId === "string" &&
792
+ typeof session.state.expectedRevision === "number"
793
+ ? await panels.update(mutation, {
794
+ resourceId: session.state.panelId,
795
+ expectedRevision: session.state.expectedRevision,
796
+ draft,
797
+ })
798
+ : await panels.create(mutation, draft);
799
+ await sessions.delete(session.sessionId);
800
+ await context.reply({
801
+ ephemeral: true,
802
+ componentsV2: {
803
+ text: [
804
+ {
805
+ content: `Reaction Roles panel **${panel.title}** is active in <#${panel.channelId}>.`,
806
+ },
807
+ ],
808
+ },
809
+ allowedUserMentionIds: [],
810
+ });
811
+ }
812
+ async function cancelDraft(context) {
813
+ const guildId = requireGuild(context);
814
+ const sessions = sessionService(context.services);
815
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
816
+ if (session)
817
+ await sessions.delete(session.sessionId);
818
+ await context.reply({
819
+ ephemeral: true,
820
+ componentsV2: { text: [{ content: "Nothing was changed." }] },
821
+ });
822
+ }
823
+ async function confirmDelete(context) {
824
+ const guildId = requireGuild(context);
825
+ const sessions = sessionService(context.services);
826
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
827
+ if (!session ||
828
+ session.state.mode !== "delete" ||
829
+ typeof session.state.panelId !== "string" ||
830
+ typeof session.state.expectedRevision !== "number")
831
+ return replyExpired(context);
832
+ await panels.delete(interactionMutationContext(context, guildId), session.state.panelId, session.state.expectedRevision);
833
+ await sessions.delete(session.sessionId);
834
+ await context.reply({
835
+ ephemeral: true,
836
+ componentsV2: {
837
+ text: [
838
+ {
839
+ content: "The role panel was deleted. Existing member roles were not changed.",
840
+ },
841
+ ],
842
+ },
843
+ });
844
+ }
845
+ async function toggleSlot(context, index) {
846
+ const { guildId, panel } = await activePanelForInteraction(context);
847
+ const entry = panel.entries[index];
848
+ if (!entry)
849
+ return stalePanelReply(context);
850
+ await consumeMemberLimit(context.services, guildId, context.userId);
851
+ let changed = "removed";
852
+ try {
853
+ await withMemberLock(guildId, panel.panelId, context.userId, async () => {
854
+ const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
855
+ const current = new Set(await roles.getCurrentRoleIds({
856
+ guildId,
857
+ memberUserId: context.userId,
858
+ }));
859
+ const changes = [];
860
+ if (panel.selectionMode === "one_only") {
861
+ const desired = current.has(entry.roleId) ? [] : [entry.roleId];
862
+ const result = await roles.reconcileApprovedRoles({
863
+ guildId,
864
+ memberUserId: context.userId,
865
+ allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
866
+ desiredRoleIds: desired,
867
+ moduleId: MODULE_ID,
868
+ reason: `Reaction Roles panel ${panel.panelId}`,
869
+ });
870
+ changes.push(...result.added.map((roleId) => ({
871
+ roleId,
872
+ outcome: "added",
873
+ })), ...result.removed.map((roleId) => ({
874
+ roleId,
875
+ outcome: "removed",
876
+ })));
877
+ changed = result.added.includes(entry.roleId) ? "added" : "removed";
878
+ }
879
+ else if (current.has(entry.roleId)) {
880
+ await roles.removeApprovedRole({
881
+ guildId,
882
+ memberUserId: context.userId,
883
+ roleId: entry.roleId,
884
+ moduleId: MODULE_ID,
885
+ reason: `Reaction Roles panel ${panel.panelId}`,
886
+ });
887
+ changed = "removed";
888
+ changes.push({ roleId: entry.roleId, outcome: changed });
889
+ }
890
+ else {
891
+ await roles.addApprovedRole({
892
+ guildId,
893
+ memberUserId: context.userId,
894
+ roleId: entry.roleId,
895
+ moduleId: MODULE_ID,
896
+ reason: `Reaction Roles panel ${panel.panelId}`,
897
+ });
898
+ changed = "added";
899
+ changes.push({ roleId: entry.roleId, outcome: changed });
900
+ }
901
+ await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, changes);
902
+ });
903
+ }
904
+ catch {
905
+ await roleFailure(context, panel);
906
+ return;
907
+ }
908
+ await context.reply(memberOutcome(entry.nameplate, changed));
909
+ }
910
+ async function reconcileSelection(context) {
911
+ const { guildId, panel } = await activePanelForInteraction(context);
912
+ if (panel.controlType !== "dropdown")
913
+ return stalePanelReply(context);
914
+ await consumeMemberLimit(context.services, guildId, context.userId);
915
+ const selected = new Set(context.selectedValues);
916
+ const desired = panel.entries
917
+ .filter(({ entryId }) => selected.has(entryId))
918
+ .map(({ roleId }) => roleId);
919
+ if (selected.size !== desired.length ||
920
+ (panel.selectionMode === "one_only" && desired.length > 1))
921
+ return stalePanelReply(context);
922
+ let added = 0;
923
+ let removed = 0;
924
+ try {
925
+ await withMemberLock(guildId, panel.panelId, context.userId, async () => {
926
+ const result = await context.services
927
+ .get(HELYX_SERVICE_NAMES.memberRoles)
928
+ .reconcileApprovedRoles({
929
+ guildId,
930
+ memberUserId: context.userId,
931
+ allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
932
+ desiredRoleIds: desired,
933
+ moduleId: MODULE_ID,
934
+ reason: `Reaction Roles panel ${panel.panelId}`,
935
+ });
936
+ added = result.added.length;
937
+ removed = result.removed.length;
938
+ await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, [
939
+ ...result.added.map((roleId) => ({
940
+ roleId,
941
+ outcome: "added",
942
+ })),
943
+ ...result.removed.map((roleId) => ({
944
+ roleId,
945
+ outcome: "removed",
946
+ })),
947
+ ]);
948
+ });
949
+ }
950
+ catch {
951
+ await roleFailure(context, panel);
952
+ return;
953
+ }
954
+ await context.reply({
955
+ ephemeral: true,
956
+ componentsV2: {
957
+ text: [
958
+ {
959
+ content: `Your panel roles were updated: ${added} added, ${removed} removed.`,
960
+ },
961
+ ],
962
+ },
963
+ });
964
+ }
965
+ async function removeAllPanelRoles(context) {
966
+ const { guildId, panel } = await activePanelForInteraction(context);
967
+ await consumeMemberLimit(context.services, guildId, context.userId);
968
+ let removed = [];
969
+ try {
970
+ await withMemberLock(guildId, panel.panelId, context.userId, async () => {
971
+ const result = await context.services
972
+ .get(HELYX_SERVICE_NAMES.memberRoles)
973
+ .reconcileApprovedRoles({
974
+ guildId,
975
+ memberUserId: context.userId,
976
+ allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
977
+ desiredRoleIds: [],
978
+ moduleId: MODULE_ID,
979
+ reason: `Reaction Roles panel ${panel.panelId}`,
980
+ });
981
+ removed = result.removed;
982
+ await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, result.removed.map((roleId) => ({
983
+ roleId,
984
+ outcome: "removed",
985
+ })));
986
+ });
987
+ }
988
+ catch {
989
+ await roleFailure(context, panel);
990
+ return;
991
+ }
992
+ await context.reply({
993
+ ephemeral: true,
994
+ componentsV2: {
995
+ text: [
996
+ {
997
+ content: removed.length === 0
998
+ ? "You did not have any roles from this panel."
999
+ : `${removed.length} panel ${removed.length === 1 ? "role was" : "roles were"} removed.`,
1000
+ },
1001
+ ],
1002
+ },
1003
+ });
1004
+ }
1005
+ async function handleReactionAdd(context) {
1006
+ if (context.reactionType !== "normal")
1007
+ return;
1008
+ const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
1009
+ if (!panel || panel.controlType !== "reactions")
1010
+ return;
1011
+ const entry = panel.entries.find((candidate) => candidate.emoji &&
1012
+ canonicalEmojiKey(candidate.emoji) === context.emojiKey);
1013
+ if (!entry)
1014
+ return;
1015
+ const changes = [];
1016
+ try {
1017
+ await withMemberLock(context.serverId, panel.panelId, context.userId, async () => {
1018
+ const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
1019
+ if (panel.selectionMode === "one_only") {
1020
+ const result = await roles.reconcileApprovedRoles({
1021
+ guildId: context.serverId,
1022
+ memberUserId: context.userId,
1023
+ allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
1024
+ desiredRoleIds: [entry.roleId],
1025
+ moduleId: MODULE_ID,
1026
+ reason: `Reaction Roles panel ${panel.panelId}`,
1027
+ });
1028
+ changes.push(...result.added.map((roleId) => ({
1029
+ roleId,
1030
+ outcome: "added",
1031
+ })), ...result.removed.map((roleId) => ({
1032
+ roleId,
1033
+ outcome: "removed",
1034
+ })));
1035
+ return;
1036
+ }
1037
+ const result = await roles.addApprovedRole({
1038
+ guildId: context.serverId,
1039
+ memberUserId: context.userId,
1040
+ roleId: entry.roleId,
1041
+ moduleId: MODULE_ID,
1042
+ reason: `Reaction Roles panel ${panel.panelId}`,
1043
+ });
1044
+ if (result.changed)
1045
+ changes.push({ roleId: entry.roleId, outcome: "added" });
1046
+ });
1047
+ await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, changes);
1048
+ }
1049
+ catch {
1050
+ await panels.markNeedsAttention(panel, context.services).catch(() => false);
1051
+ await context.removeAddedReaction().catch(() => undefined);
1052
+ return;
1053
+ }
1054
+ if (!panel.persistentReactions) {
1055
+ try {
1056
+ await context.removeAddedReaction();
1057
+ }
1058
+ catch {
1059
+ await panels
1060
+ .markNeedsAttention(panel, context.services)
1061
+ .catch(() => false);
1062
+ }
1063
+ }
1064
+ }
1065
+ async function handleReactionRemove(context) {
1066
+ if (context.reactionType !== "normal")
1067
+ return;
1068
+ const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
1069
+ if (!panel || panel.controlType !== "reactions" || !panel.persistentReactions)
1070
+ return;
1071
+ const entry = panel.entries.find((candidate) => candidate.emoji &&
1072
+ canonicalEmojiKey(candidate.emoji) === context.emojiKey);
1073
+ if (!entry)
1074
+ return;
1075
+ try {
1076
+ await withMemberLock(context.serverId, panel.panelId, context.userId, () => context.services
1077
+ .get(HELYX_SERVICE_NAMES.memberRoles)
1078
+ .removeApprovedRole({
1079
+ guildId: context.serverId,
1080
+ memberUserId: context.userId,
1081
+ roleId: entry.roleId,
1082
+ moduleId: MODULE_ID,
1083
+ reason: `Reaction Roles panel ${panel.panelId}`,
1084
+ })
1085
+ .then(() => undefined));
1086
+ await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, [{ roleId: entry.roleId, outcome: "removed" }]);
1087
+ }
1088
+ catch {
1089
+ await panels.markNeedsAttention(panel, context.services).catch(() => false);
1090
+ }
1091
+ }
1092
+ async function activePanelForInteraction(context) {
1093
+ const guildId = context.serverId;
1094
+ if (!guildId || !context.messageId)
1095
+ throw new Error("Server panel required");
1096
+ const panel = await panels.resolveActive(guildId, context.messageId, context.services);
1097
+ if (!panel)
1098
+ throw new Error("Role panel is no longer active");
1099
+ return { guildId, panel };
1100
+ }
1101
+ async function consumeMemberLimit(services, guildId, userId) {
1102
+ const result = await services
1103
+ .get(HELYX_SERVICE_NAMES.rateLimits)
1104
+ .consume({
1105
+ scope: "user",
1106
+ subjectId: userId,
1107
+ action: `${MODULE_ID}:${guildId}:member-role`,
1108
+ limit: 10,
1109
+ windowSeconds: 10,
1110
+ });
1111
+ if (!result.allowed)
1112
+ throw new Error("Role panel rate limit reached");
1113
+ }
1114
+ async function withMemberLock(guildId, panelId, userId, operation) {
1115
+ const key = `${guildId}:${panelId}:${userId}`;
1116
+ const previous = memberLocks.get(key) ?? Promise.resolve();
1117
+ const current = previous.catch(() => undefined).then(operation);
1118
+ memberLocks.set(key, current);
1119
+ try {
1120
+ await current;
1121
+ }
1122
+ finally {
1123
+ if (memberLocks.get(key) === current)
1124
+ memberLocks.delete(key);
1125
+ }
1126
+ }
1127
+ function basicModal(panel, editing = false) {
1128
+ return {
1129
+ customId: COMPONENT_IDS.basicModal,
1130
+ title: editing ? "Edit role panel" : "Create role panel",
1131
+ fields: [
1132
+ {
1133
+ type: "discord-channel",
1134
+ customId: "channel",
1135
+ label: "Destination channel",
1136
+ required: true,
1137
+ channelTypes: ["guild_text", "guild_announcement"],
1138
+ ...(panel?.channelId ? { defaultValues: [panel.channelId] } : {}),
1139
+ },
1140
+ {
1141
+ customId: "title",
1142
+ label: "Title",
1143
+ style: "short",
1144
+ required: true,
1145
+ minLength: 1,
1146
+ maxLength: 80,
1147
+ ...(panel?.title ? { value: panel.title } : {}),
1148
+ },
1149
+ {
1150
+ customId: "message",
1151
+ label: "Message",
1152
+ style: "paragraph",
1153
+ required: true,
1154
+ minLength: 1,
1155
+ maxLength: 1_000,
1156
+ ...(panel?.messageBody ? { value: panel.messageBody } : {}),
1157
+ },
1158
+ {
1159
+ type: "string-select",
1160
+ customId: "style",
1161
+ label: "Message style",
1162
+ required: true,
1163
+ options: [
1164
+ {
1165
+ label: "Text",
1166
+ value: "text",
1167
+ default: panel?.messageStyle !== "embed",
1168
+ },
1169
+ {
1170
+ label: "Embed",
1171
+ value: "embed",
1172
+ default: panel?.messageStyle === "embed",
1173
+ },
1174
+ ],
1175
+ },
1176
+ {
1177
+ type: "string-select",
1178
+ customId: "control",
1179
+ label: "Role controls",
1180
+ required: true,
1181
+ options: [
1182
+ {
1183
+ label: "Buttons",
1184
+ value: "buttons",
1185
+ default: !panel?.controlType || panel.controlType === "buttons",
1186
+ },
1187
+ {
1188
+ label: "Dropdown",
1189
+ value: "dropdown",
1190
+ default: panel?.controlType === "dropdown",
1191
+ },
1192
+ {
1193
+ label: "Emoji reactions",
1194
+ value: "reactions",
1195
+ default: panel?.controlType === "reactions",
1196
+ },
1197
+ ],
1198
+ },
1199
+ ],
1200
+ };
1201
+ }
1202
+ function existingDraftResponse() {
1203
+ return {
1204
+ ephemeral: true,
1205
+ componentsV2: {
1206
+ text: [
1207
+ {
1208
+ content: "You already have a Reaction Roles draft. Continue it or replace it with a new draft.",
1209
+ },
1210
+ ],
1211
+ },
1212
+ buttons: [
1213
+ {
1214
+ type: "button",
1215
+ customId: COMPONENT_IDS.continueDraft,
1216
+ label: "Continue draft",
1217
+ style: "primary",
1218
+ },
1219
+ {
1220
+ type: "button",
1221
+ customId: COMPONENT_IDS.replaceDraft,
1222
+ label: "Replace draft",
1223
+ style: "danger",
1224
+ },
1225
+ {
1226
+ type: "button",
1227
+ customId: COMPONENT_IDS.keepDraft,
1228
+ label: "Cancel",
1229
+ style: "secondary",
1230
+ },
1231
+ ],
1232
+ allowedUserMentionIds: [],
1233
+ };
1234
+ }
1235
+ function defaultsFromSessionState(state) {
1236
+ return {
1237
+ ...(typeof state.channelId === "string"
1238
+ ? { channelId: state.channelId }
1239
+ : {}),
1240
+ ...(state.messageStyle === "text" || state.messageStyle === "embed"
1241
+ ? { messageStyle: state.messageStyle }
1242
+ : {}),
1243
+ ...(state.controlType === "buttons" ||
1244
+ state.controlType === "dropdown" ||
1245
+ state.controlType === "reactions"
1246
+ ? { controlType: state.controlType }
1247
+ : {}),
1248
+ };
1249
+ }
1250
+ function managedObject(value) {
1251
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1252
+ }
1253
+ function entryModal(entry, editing = false) {
1254
+ return {
1255
+ customId: COMPONENT_IDS.entryModal,
1256
+ title: editing ? "Edit role entry" : "Add a role entry",
1257
+ fields: [
1258
+ {
1259
+ type: "discord-role",
1260
+ customId: "role",
1261
+ label: "Discord role",
1262
+ required: true,
1263
+ ...(entry ? { defaultValues: [entry.roleId] } : {}),
1264
+ },
1265
+ {
1266
+ customId: "nameplate",
1267
+ label: "Public label",
1268
+ style: "short",
1269
+ required: true,
1270
+ minLength: 1,
1271
+ maxLength: 80,
1272
+ ...(entry ? { value: entry.nameplate } : {}),
1273
+ },
1274
+ {
1275
+ customId: "emoji",
1276
+ label: "Emoji",
1277
+ description: "Optional for buttons and dropdowns; required for reactions.",
1278
+ style: "short",
1279
+ required: false,
1280
+ minLength: 1,
1281
+ maxLength: 100,
1282
+ ...(entry?.emoji ? { value: entry.emoji } : {}),
1283
+ placeholder: "✅ or <:updates:123456789012345678>",
1284
+ },
1285
+ {
1286
+ type: "string-select",
1287
+ customId: "button-style",
1288
+ label: "Button colour",
1289
+ required: false,
1290
+ options: [
1291
+ { label: "Grey", value: "secondary", default: true },
1292
+ { label: "Blue", value: "primary" },
1293
+ { label: "Green", value: "success" },
1294
+ { label: "Red", value: "danger" },
1295
+ ],
1296
+ },
1297
+ ...(editing
1298
+ ? [
1299
+ {
1300
+ type: "string-select",
1301
+ customId: "entry-action",
1302
+ label: "Entry action",
1303
+ required: true,
1304
+ options: [
1305
+ { label: "Save changes", value: "save", default: true },
1306
+ { label: "Move up", value: "move-up" },
1307
+ { label: "Move down", value: "move-down" },
1308
+ { label: "Remove entry", value: "remove" },
1309
+ ],
1310
+ },
1311
+ ]
1312
+ : []),
1313
+ ],
1314
+ };
1315
+ }
1316
+ function builderResponse(draft, services) {
1317
+ const builder = services.get(HELYX_SERVICE_NAMES.messageBuilder);
1318
+ const panel = draft.entries.length > 0
1319
+ ? builder.build(panelMessageDraft(draft)).response
1320
+ : builder.build({
1321
+ presentation: draft.messageStyle === "embed"
1322
+ ? {
1323
+ type: "embed",
1324
+ title: draft.title,
1325
+ body: draft.messageBody,
1326
+ ...(draft.embedColour === undefined
1327
+ ? {}
1328
+ : { color: draft.embedColour }),
1329
+ }
1330
+ : {
1331
+ type: "text",
1332
+ title: draft.title,
1333
+ body: draft.messageBody,
1334
+ accentColor: 0x7c3aed,
1335
+ },
1336
+ allowedUserMentionIds: [],
1337
+ }).response;
1338
+ return {
1339
+ ...panel,
1340
+ ephemeral: true,
1341
+ selects: draft.entries.length === 0
1342
+ ? []
1343
+ : [
1344
+ {
1345
+ type: "string",
1346
+ customId: COMPONENT_IDS.builderEntries,
1347
+ placeholder: "Edit, reorder or remove a role",
1348
+ minValues: 1,
1349
+ maxValues: 1,
1350
+ options: draft.entries.map((entry) => ({
1351
+ label: entry.nameplate,
1352
+ value: entry.entryId,
1353
+ ...(entry.emoji
1354
+ ? {
1355
+ emojiIdentifier: canonicaliseEmoji(entry.emoji)
1356
+ .identifier,
1357
+ }
1358
+ : {}),
1359
+ })),
1360
+ },
1361
+ ],
1362
+ buttons: [
1363
+ {
1364
+ type: "button",
1365
+ customId: COMPONENT_IDS.addEntry,
1366
+ label: "Add role",
1367
+ style: "secondary",
1368
+ disabled: draft.entries.length >= PANEL_LIMITS[draft.controlType],
1369
+ },
1370
+ {
1371
+ type: "button",
1372
+ customId: COMPONENT_IDS.publish,
1373
+ label: "Publish panel",
1374
+ style: "success",
1375
+ disabled: draft.entries.length === 0,
1376
+ },
1377
+ {
1378
+ type: "button",
1379
+ customId: COMPONENT_IDS.cancel,
1380
+ label: "Cancel",
1381
+ style: "secondary",
1382
+ },
1383
+ ],
1384
+ allowedUserMentionIds: [],
1385
+ };
1386
+ }
1387
+ function interactionMutationContext(context, guildId) {
1388
+ return {
1389
+ guildId,
1390
+ actorUserId: context.userId,
1391
+ source: "discord",
1392
+ services: context.services,
1393
+ correlationId: context.interactionId,
1394
+ sendToChannel: (channelId, response) => context.sendToChannel(channelId, response),
1395
+ deleteChannelMessage: (channelId, messageId) => context.deleteChannelMessage(channelId, messageId),
1396
+ editChannelMessage: (channelId, messageId, response) => context.editChannelMessage(channelId, messageId, response),
1397
+ addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => context.addChannelMessageReaction(channelId, messageId, emojiIdentifier),
1398
+ };
1399
+ }
1400
+ async function findPanelReference(services, guildId, value) {
1401
+ const reference = value?.trim();
1402
+ if (!reference)
1403
+ throw new Error("Role panel reference is required");
1404
+ const panel = await panels.repository(services).findById(guildId, reference);
1405
+ if (!panel)
1406
+ throw new Error("Role panel was not found");
1407
+ return panel;
1408
+ }
1409
+ async function autocompletePanelReference(context) {
1410
+ if (!context.serverId || context.focusedOptionName !== "reference") {
1411
+ await context.respond([]);
1412
+ return;
1413
+ }
1414
+ const query = context.focusedValue.trim().normalize("NFKC").toLowerCase();
1415
+ const repository = panels.repository(context.services);
1416
+ const choices = [];
1417
+ let cursor;
1418
+ for (let pageNumber = 0; pageNumber < 5 && choices.length < 25; pageNumber += 1) {
1419
+ const page = await repository.listReferences({
1420
+ guildId: context.serverId,
1421
+ ...(cursor ? { cursor } : {}),
1422
+ limit: query ? 100 : 25,
1423
+ });
1424
+ for (const panel of page.panels) {
1425
+ if (panel.status === "deleting")
1426
+ continue;
1427
+ const searchable = `${panel.title} ${panel.panelId} ${panel.status}`.toLowerCase();
1428
+ if (query && !searchable.includes(query))
1429
+ continue;
1430
+ const title = panel.title.replaceAll(/\s+/gu, " ").trim();
1431
+ const suffix = `${panel.status} · ${panel.panelId.slice(0, 8)}`;
1432
+ choices.push({
1433
+ name: `${title.slice(0, Math.max(1, 97 - suffix.length))} · ${suffix}`,
1434
+ value: panel.panelId,
1435
+ });
1436
+ if (choices.length === 25)
1437
+ break;
1438
+ }
1439
+ if (!query || !page.nextCursor)
1440
+ break;
1441
+ cursor = page.nextCursor;
1442
+ }
1443
+ await context.respond(choices);
1444
+ }
1445
+ function referenceOption() {
1446
+ return {
1447
+ type: "string",
1448
+ name: "reference",
1449
+ description: "Panel reference shown by /reaction-role list.",
1450
+ required: true,
1451
+ minLength: 1,
1452
+ maxLength: 100,
1453
+ autocomplete: true,
1454
+ };
1455
+ }
1456
+ function sessionService(services) {
1457
+ return services.get(HELYX_SERVICE_NAMES.sessions);
1458
+ }
1459
+ function requireGuild(context) {
1460
+ if (!context.serverId)
1461
+ throw new Error("Use this command in a Discord server");
1462
+ return context.serverId;
1463
+ }
1464
+ function expiresAt() {
1465
+ return new Date(Date.now() + 20 * 60 * 1_000);
1466
+ }
1467
+ function serializeDraft(draft) {
1468
+ return {
1469
+ channelId: draft.channelId,
1470
+ title: draft.title,
1471
+ messageBody: draft.messageBody,
1472
+ messageStyle: draft.messageStyle,
1473
+ controlType: draft.controlType,
1474
+ selectionMode: draft.selectionMode,
1475
+ persistentReactions: draft.persistentReactions,
1476
+ ...(draft.embedColour === undefined
1477
+ ? {}
1478
+ : { embedColour: draft.embedColour }),
1479
+ ...(draft.dropdownPlaceholder === undefined
1480
+ ? {}
1481
+ : { dropdownPlaceholder: draft.dropdownPlaceholder }),
1482
+ entries: draft.entries.map((entry) => ({ ...entry })),
1483
+ };
1484
+ }
1485
+ function parseSessionDraft(value) {
1486
+ if (!isObject(value) || !Array.isArray(value.entries))
1487
+ return null;
1488
+ try {
1489
+ return validatePanelDraft({
1490
+ channelId: stringValue(value.channelId),
1491
+ title: stringValue(value.title),
1492
+ messageBody: stringValue(value.messageBody),
1493
+ messageStyle: stringValue(value.messageStyle),
1494
+ controlType: stringValue(value.controlType),
1495
+ selectionMode: stringValue(value.selectionMode),
1496
+ persistentReactions: typeof value.persistentReactions === "boolean"
1497
+ ? value.persistentReactions
1498
+ : true,
1499
+ ...(typeof value.embedColour === "number"
1500
+ ? { embedColour: value.embedColour }
1501
+ : {}),
1502
+ ...(typeof value.dropdownPlaceholder === "string"
1503
+ ? { dropdownPlaceholder: value.dropdownPlaceholder }
1504
+ : {}),
1505
+ entries: value.entries.map((entry) => {
1506
+ if (!isObject(entry))
1507
+ throw new Error("invalid entry");
1508
+ return {
1509
+ entryId: stringValue(entry.entryId),
1510
+ roleId: stringValue(entry.roleId),
1511
+ nameplate: stringValue(entry.nameplate),
1512
+ ...(typeof entry.emoji === "string" ? { emoji: entry.emoji } : {}),
1513
+ ...(typeof entry.buttonStyle === "string"
1514
+ ? { buttonStyle: entry.buttonStyle }
1515
+ : {}),
1516
+ };
1517
+ }),
1518
+ }, { allowEmptyEntries: true });
1519
+ }
1520
+ catch {
1521
+ return null;
1522
+ }
1523
+ }
1524
+ async function replyExpired(context) {
1525
+ await context.reply({
1526
+ ephemeral: true,
1527
+ componentsV2: {
1528
+ text: [
1529
+ {
1530
+ content: "This role-panel builder expired. Run `/reaction-role create` or `/reaction-role edit` again.",
1531
+ },
1532
+ ],
1533
+ },
1534
+ });
1535
+ }
1536
+ async function stalePanelReply(context) {
1537
+ await context.reply({
1538
+ ephemeral: true,
1539
+ componentsV2: {
1540
+ text: [
1541
+ {
1542
+ content: "This role panel changed or is no longer active. Ask a server manager to repair it.",
1543
+ },
1544
+ ],
1545
+ },
1546
+ });
1547
+ }
1548
+ function memberOutcome(nameplate, changed) {
1549
+ return {
1550
+ ephemeral: true,
1551
+ componentsV2: {
1552
+ text: [
1553
+ {
1554
+ content: `${nameplate} was ${changed === "added" ? "added to" : "removed from"} your roles.`,
1555
+ },
1556
+ ],
1557
+ },
1558
+ allowedUserMentionIds: [],
1559
+ };
1560
+ }
1561
+ async function roleFailure(context, panel) {
1562
+ await panels.markNeedsAttention(panel, context.services).catch(() => false);
1563
+ await context.reply({
1564
+ ephemeral: true,
1565
+ componentsV2: {
1566
+ text: [
1567
+ {
1568
+ content: "Helyx cannot manage that role right now. A server administrator needs to check the Reaction Roles panel.",
1569
+ },
1570
+ ],
1571
+ },
1572
+ allowedUserMentionIds: [],
1573
+ });
1574
+ }
1575
+ async function emitMemberChanges(services, guildId, memberUserId, panelId, changes) {
1576
+ if (changes.length === 0 || !services.has(HELYX_SERVICE_NAMES.moduleLogging))
1577
+ return;
1578
+ const logging = services.get(HELYX_SERVICE_NAMES.moduleLogging);
1579
+ await Promise.all(changes.slice(0, 25).map(({ roleId, outcome }) => logging
1580
+ .emit({
1581
+ guildId,
1582
+ moduleId: MODULE_ID,
1583
+ eventId: "reaction-role-member-changed",
1584
+ summary: "A member changed an approved Reaction Roles role.",
1585
+ details: [
1586
+ { label: "Member ID", value: memberUserId },
1587
+ { label: "Panel", value: panelId },
1588
+ { label: "Role ID", value: roleId },
1589
+ { label: "Outcome", value: outcome },
1590
+ ],
1591
+ })
1592
+ .catch(() => ({ delivered: false }))));
1593
+ }
1594
+ function requiredStyle(values) {
1595
+ const value = values[0];
1596
+ if (value !== "text" && value !== "embed")
1597
+ throw new Error("Choose Text or Embed");
1598
+ return value;
1599
+ }
1600
+ function requiredControl(values) {
1601
+ const value = values[0];
1602
+ if (value !== "buttons" && value !== "dropdown" && value !== "reactions")
1603
+ throw new Error("Choose valid role controls");
1604
+ return value;
1605
+ }
1606
+ function canonicalEmojiKey(value) {
1607
+ const custom = /^([A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(value);
1608
+ return custom ? `custom:${custom[2]}` : `unicode:${value.normalize("NFKC")}`;
1609
+ }
1610
+ function isObject(value) {
1611
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1612
+ }
1613
+ function stringValue(value) {
1614
+ if (typeof value !== "string")
1615
+ throw new Error("invalid string");
1616
+ return value;
1617
+ }
1618
+ export default reactionRolesModule;