@helyx/module-reactionroles 1.0.15 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +37 -53
- package/dist/builder-interactions.d.ts +11 -0
- package/dist/builder-interactions.js +293 -0
- package/dist/builder-presentation.d.ts +30 -0
- package/dist/builder-presentation.js +343 -0
- package/dist/builder.d.ts +3 -0
- package/dist/builder.js +22 -16
- package/dist/cleanup-repository.d.ts +24 -0
- package/dist/cleanup-repository.js +118 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +4 -1
- package/dist/health.d.ts +16 -0
- package/dist/health.js +85 -0
- package/dist/index.js +66 -1147
- package/dist/logging.d.ts +33 -0
- package/dist/logging.js +40 -0
- package/dist/managed-resource.d.ts +2 -0
- package/dist/managed-resource.js +72 -0
- package/dist/member-interactions.d.ts +27 -0
- package/dist/member-interactions.js +360 -0
- package/dist/module-lifecycle.d.ts +3 -0
- package/dist/module-lifecycle.js +48 -0
- package/dist/panel-cache.d.ts +30 -0
- package/dist/panel-cache.js +197 -0
- package/dist/panel-create.d.ts +10 -0
- package/dist/panel-create.js +80 -0
- package/dist/panel-lifecycle.d.ts +47 -0
- package/dist/panel-lifecycle.js +272 -0
- package/dist/panel-managed.d.ts +14 -0
- package/dist/panel-managed.js +27 -0
- package/dist/panel-service.d.ts +13 -36
- package/dist/panel-service.js +48 -722
- package/dist/panel-support.d.ts +35 -0
- package/dist/panel-support.js +291 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +8 -0
- package/dist/privacy.d.ts +5 -0
- package/dist/privacy.js +12 -0
- package/dist/record-mapping.d.ts +28 -0
- package/dist/record-mapping.js +161 -0
- package/dist/record-schema.d.ts +203 -0
- package/dist/record-schema.js +137 -0
- package/dist/records.d.ts +9 -201
- package/dist/records.js +32 -383
- package/dist/runtime-state.d.ts +9 -0
- package/dist/runtime-state.js +30 -0
- package/manifest.json +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,83 +1,17 @@
|
|
|
1
1
|
import { parseModuleManifest } from "@helyx/module-manifest";
|
|
2
|
-
import {
|
|
2
|
+
import { defineModule } from "@helyx/sdk";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
+
}
|