@helyx/module-reactionroles 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,623 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { MODULE_ID } from "./constants.js";
3
+ export const reactionRoleRecords = {
4
+ panels: {
5
+ table: "reactionroles_panels",
6
+ fields: {
7
+ panel_id: { type: "string" },
8
+ guild_id: { type: "string" },
9
+ channel_id: { type: "string" },
10
+ message_id: { type: "string", optional: true },
11
+ title: { type: "string" },
12
+ message_body: { type: "string" },
13
+ message_style: { type: "string" },
14
+ control_type: { type: "string" },
15
+ selection_mode: { type: "string" },
16
+ persistent_reactions: { type: "boolean" },
17
+ embed_colour: { type: "number", optional: true },
18
+ dropdown_placeholder: { type: "string", optional: true },
19
+ status: { type: "string" },
20
+ revision: { type: "number" },
21
+ created_by_user_id: { type: "string", optional: true },
22
+ updated_by_user_id: { type: "string", optional: true },
23
+ created_at: { type: "date" },
24
+ updated_at: { type: "date" },
25
+ },
26
+ uniqueKeys: [["panel_id"], ["guild_id", "message_id"]],
27
+ readableFields: [
28
+ "panel_id",
29
+ "guild_id",
30
+ "channel_id",
31
+ "message_id",
32
+ "title",
33
+ "message_body",
34
+ "message_style",
35
+ "control_type",
36
+ "selection_mode",
37
+ "persistent_reactions",
38
+ "embed_colour",
39
+ "dropdown_placeholder",
40
+ "status",
41
+ "revision",
42
+ "created_by_user_id",
43
+ "updated_by_user_id",
44
+ "created_at",
45
+ "updated_at",
46
+ ],
47
+ writableFields: [
48
+ "channel_id",
49
+ "message_id",
50
+ "title",
51
+ "message_body",
52
+ "message_style",
53
+ "control_type",
54
+ "selection_mode",
55
+ "persistent_reactions",
56
+ "embed_colour",
57
+ "dropdown_placeholder",
58
+ "status",
59
+ "revision",
60
+ "updated_by_user_id",
61
+ "updated_at",
62
+ ],
63
+ orderKeys: [["updated_at", "panel_id"]],
64
+ groupableFields: ["status"],
65
+ },
66
+ entries: {
67
+ table: "reactionroles_entries",
68
+ fields: {
69
+ entry_id: { type: "string" },
70
+ panel_id: { type: "string" },
71
+ position: { type: "number" },
72
+ role_id: { type: "string" },
73
+ nameplate: { type: "string" },
74
+ emoji_kind: { type: "string", optional: true },
75
+ emoji_name: { type: "string", optional: true },
76
+ emoji_id: { type: "string", optional: true },
77
+ emoji_animated: { type: "boolean", optional: true },
78
+ button_style: { type: "string", optional: true },
79
+ created_at: { type: "date" },
80
+ updated_at: { type: "date" },
81
+ },
82
+ uniqueKeys: [
83
+ ["entry_id"],
84
+ ["panel_id", "position"],
85
+ ["panel_id", "role_id"],
86
+ ],
87
+ readableFields: [
88
+ "entry_id",
89
+ "panel_id",
90
+ "position",
91
+ "role_id",
92
+ "nameplate",
93
+ "emoji_kind",
94
+ "emoji_name",
95
+ "emoji_id",
96
+ "emoji_animated",
97
+ "button_style",
98
+ "created_at",
99
+ "updated_at",
100
+ ],
101
+ orderKeys: [["position", "entry_id"]],
102
+ },
103
+ message_cleanup: {
104
+ table: "reactionroles_message_cleanup",
105
+ fields: {
106
+ cleanup_id: { type: "string" },
107
+ guild_id: { type: "string" },
108
+ panel_id: { type: "string" },
109
+ channel_id: { type: "string" },
110
+ message_id: { type: "string" },
111
+ reason: { type: "string" },
112
+ attempts: { type: "number" },
113
+ next_attempt_at: { type: "date" },
114
+ created_at: { type: "date" },
115
+ completed_at: { type: "date", optional: true },
116
+ },
117
+ uniqueKeys: [
118
+ ["cleanup_id"],
119
+ ["guild_id", "channel_id", "message_id", "reason"],
120
+ ],
121
+ readableFields: [
122
+ "cleanup_id",
123
+ "guild_id",
124
+ "panel_id",
125
+ "channel_id",
126
+ "message_id",
127
+ "reason",
128
+ "attempts",
129
+ "next_attempt_at",
130
+ "created_at",
131
+ "completed_at",
132
+ ],
133
+ writableFields: ["attempts", "next_attempt_at", "completed_at"],
134
+ orderKeys: [["next_attempt_at", "cleanup_id"]],
135
+ },
136
+ };
137
+ const panelSelect = reactionRoleRecords.panels.readableFields;
138
+ const entrySelect = reactionRoleRecords.entries.readableFields;
139
+ const cleanupSelect = reactionRoleRecords.message_cleanup.readableFields;
140
+ export class ReactionRolePanelRepository {
141
+ records;
142
+ constructor(records) {
143
+ this.records = records;
144
+ }
145
+ async create(input) {
146
+ const now = input.now ?? new Date();
147
+ const result = await this.records.replaceOwnedSet({
148
+ moduleId: MODULE_ID,
149
+ collection: "entries",
150
+ owner: { panel_id: input.panelId },
151
+ values: input.entries.map((entry, position) => entryValues(input.panelId, entry, position, now)),
152
+ uniqueBy: ["entry_id"],
153
+ parent: {
154
+ collection: "panels",
155
+ values: panelValues({
156
+ panelId: input.panelId,
157
+ guildId: input.guildId,
158
+ actorUserId: input.actorUserId,
159
+ draft: input.draft,
160
+ ...(input.messageId ? { messageId: input.messageId } : {}),
161
+ status: input.messageId ? "active" : "pending",
162
+ revision: 1,
163
+ now,
164
+ }),
165
+ uniqueBy: ["panel_id"],
166
+ },
167
+ });
168
+ return result.applied;
169
+ }
170
+ async update(input) {
171
+ const now = input.now ?? new Date();
172
+ const revision = input.panel.revision + 1;
173
+ const result = await this.records.replaceOwnedSet({
174
+ moduleId: MODULE_ID,
175
+ collection: "entries",
176
+ owner: { panel_id: input.panel.panelId },
177
+ values: input.entries.map((entry, position) => entryValues(input.panel.panelId, entry, position, now)),
178
+ uniqueBy: ["entry_id"],
179
+ guard: {
180
+ collection: "panels",
181
+ where: { panel_id: input.panel.panelId },
182
+ expected: { field: "revision", value: input.panel.revision },
183
+ values: {
184
+ channel_id: input.draft.channelId,
185
+ ...(input.messageId === null
186
+ ? {}
187
+ : { message_id: input.messageId ?? input.panel.messageId ?? "" }),
188
+ title: input.draft.title,
189
+ message_body: input.draft.messageBody,
190
+ message_style: input.draft.messageStyle,
191
+ control_type: input.draft.controlType,
192
+ selection_mode: input.draft.selectionMode,
193
+ persistent_reactions: input.draft.persistentReactions,
194
+ ...(input.draft.embedColour === undefined
195
+ ? {}
196
+ : { embed_colour: input.draft.embedColour }),
197
+ ...(input.draft.dropdownPlaceholder === undefined
198
+ ? {}
199
+ : { dropdown_placeholder: input.draft.dropdownPlaceholder }),
200
+ status: input.status ?? input.panel.status,
201
+ revision,
202
+ updated_by_user_id: input.actorUserId,
203
+ updated_at: now,
204
+ },
205
+ },
206
+ });
207
+ return result.applied;
208
+ }
209
+ async findById(guildId, panelId) {
210
+ const panel = await this.records.findOne({
211
+ moduleId: MODULE_ID,
212
+ collection: "panels",
213
+ select: panelSelect,
214
+ where: { panel_id: panelId },
215
+ });
216
+ if (!panel || panel.guild_id !== guildId)
217
+ return null;
218
+ return this.#withEntries(panel);
219
+ }
220
+ async findByMessage(guildId, messageId) {
221
+ const panel = await this.records.findOne({
222
+ moduleId: MODULE_ID,
223
+ collection: "panels",
224
+ select: panelSelect,
225
+ where: { guild_id: guildId, message_id: messageId },
226
+ });
227
+ return panel ? this.#withEntries(panel) : null;
228
+ }
229
+ async list(input) {
230
+ const page = await this.records.findMany({
231
+ moduleId: MODULE_ID,
232
+ collection: "panels",
233
+ select: panelSelect,
234
+ where: { guild_id: input.guildId },
235
+ orderBy: [
236
+ { field: "updated_at", direction: "desc" },
237
+ { field: "panel_id", direction: "asc" },
238
+ ],
239
+ ...(input.cursor ? { cursor: input.cursor } : {}),
240
+ limit: input.limit,
241
+ });
242
+ return {
243
+ panels: await Promise.all(page.records.map((panel) => this.#withEntries(panel))),
244
+ nextCursor: page.nextCursor,
245
+ };
246
+ }
247
+ async listReferences(input) {
248
+ const page = await this.records.findMany({
249
+ moduleId: MODULE_ID,
250
+ collection: "panels",
251
+ select: ["panel_id", "title", "status", "updated_at"],
252
+ where: { guild_id: input.guildId },
253
+ orderBy: [
254
+ { field: "updated_at", direction: "desc" },
255
+ { field: "panel_id", direction: "asc" },
256
+ ],
257
+ ...(input.cursor ? { cursor: input.cursor } : {}),
258
+ limit: Math.min(100, Math.max(1, input.limit)),
259
+ });
260
+ return {
261
+ panels: page.records.map((panel) => ({
262
+ panelId: requiredString(panel, "panel_id"),
263
+ title: requiredString(panel, "title"),
264
+ status: requiredEnum(panel, "status", [
265
+ "pending",
266
+ "active",
267
+ "disabled",
268
+ "message_missing",
269
+ "needs_attention",
270
+ "cleanup_pending",
271
+ "deleting",
272
+ ]),
273
+ updatedAt: requiredDate(panel, "updated_at"),
274
+ })),
275
+ nextCursor: page.nextCursor,
276
+ };
277
+ }
278
+ async listByChannel(guildId, channelId, limit = 100) {
279
+ const page = await this.records.findMany({
280
+ moduleId: MODULE_ID,
281
+ collection: "panels",
282
+ select: panelSelect,
283
+ where: { guild_id: guildId, channel_id: channelId },
284
+ orderBy: [
285
+ { field: "updated_at", direction: "desc" },
286
+ { field: "panel_id", direction: "asc" },
287
+ ],
288
+ limit: Math.min(100, Math.max(1, limit)),
289
+ });
290
+ return Promise.all(page.records.map((panel) => this.#withEntries(panel)));
291
+ }
292
+ async listByRole(guildId, roleId, limit = 100) {
293
+ return this.#listByEntryField(guildId, "role_id", roleId, limit);
294
+ }
295
+ async listByEmoji(guildId, emojiId, limit = 100) {
296
+ return this.#listByEntryField(guildId, "emoji_id", emojiId, limit);
297
+ }
298
+ async listInteractive(input) {
299
+ const page = await this.records.findMany({
300
+ moduleId: MODULE_ID,
301
+ collection: "panels",
302
+ select: panelSelect,
303
+ where: { status: input.status },
304
+ orderBy: [
305
+ { field: "updated_at", direction: "asc" },
306
+ { field: "panel_id", direction: "asc" },
307
+ ],
308
+ ...(input.cursor ? { cursor: input.cursor } : {}),
309
+ limit: input.limit,
310
+ });
311
+ return {
312
+ panels: await Promise.all(page.records.map((panel) => this.#withEntries(panel))),
313
+ nextCursor: page.nextCursor,
314
+ };
315
+ }
316
+ async markStatus(input) {
317
+ return (await this.records.updateOne({
318
+ moduleId: MODULE_ID,
319
+ collection: "panels",
320
+ where: { panel_id: input.panelId },
321
+ expected: { field: "revision", value: input.expectedRevision },
322
+ values: {
323
+ status: input.status,
324
+ revision: input.expectedRevision + 1,
325
+ ...(input.actorUserId
326
+ ? { updated_by_user_id: input.actorUserId }
327
+ : {}),
328
+ updated_at: input.now ?? new Date(),
329
+ },
330
+ })).updated;
331
+ }
332
+ async remove(panelId, expectedRevision, actorUserId) {
333
+ const claimed = await this.claimDeleting(panelId, expectedRevision, actorUserId);
334
+ if (!claimed)
335
+ return false;
336
+ return this.deleteClaimed(panelId);
337
+ }
338
+ claimDeleting(panelId, expectedRevision, actorUserId) {
339
+ return this.markStatus({
340
+ panelId,
341
+ expectedRevision,
342
+ actorUserId,
343
+ status: "deleting",
344
+ });
345
+ }
346
+ async deleteClaimed(panelId) {
347
+ return ((await this.records.deleteMany({
348
+ moduleId: MODULE_ID,
349
+ collection: "panels",
350
+ where: { panel_id: panelId, status: "deleting" },
351
+ limit: 1,
352
+ })).deleted === 1);
353
+ }
354
+ async recordCleanup(input) {
355
+ const created = await this.records.appendUnique({
356
+ moduleId: MODULE_ID,
357
+ collection: "message_cleanup",
358
+ values: {
359
+ cleanup_id: randomUUID(),
360
+ guild_id: input.guildId,
361
+ panel_id: input.panelId,
362
+ channel_id: input.channelId,
363
+ message_id: input.messageId,
364
+ reason: input.reason,
365
+ attempts: 0,
366
+ next_attempt_at: input.now ?? new Date(),
367
+ created_at: input.now ?? new Date(),
368
+ },
369
+ uniqueBy: ["guild_id", "channel_id", "message_id", "reason"],
370
+ });
371
+ return created.created;
372
+ }
373
+ async listPendingCleanup(limit = 100) {
374
+ const page = await this.records.findMany({
375
+ moduleId: MODULE_ID,
376
+ collection: "message_cleanup",
377
+ select: cleanupSelect,
378
+ where: { completed_at: null },
379
+ orderBy: [
380
+ { field: "next_attempt_at", direction: "asc" },
381
+ { field: "cleanup_id", direction: "asc" },
382
+ ],
383
+ limit: Math.min(100, Math.max(1, limit)),
384
+ });
385
+ return page.records.map((row) => ({
386
+ cleanupId: requiredString(row, "cleanup_id"),
387
+ guildId: requiredString(row, "guild_id"),
388
+ panelId: requiredString(row, "panel_id"),
389
+ channelId: requiredString(row, "channel_id"),
390
+ messageId: requiredString(row, "message_id"),
391
+ reason: requiredEnum(row, "reason", [
392
+ "replacement",
393
+ "rollback",
394
+ "delete",
395
+ ]),
396
+ attempts: requiredNumber(row, "attempts"),
397
+ nextAttemptAt: requiredDate(row, "next_attempt_at"),
398
+ }));
399
+ }
400
+ async claimCleanup(cleanup, now = new Date()) {
401
+ if (cleanup.attempts >= 10 ||
402
+ cleanup.nextAttemptAt.getTime() > now.getTime())
403
+ return null;
404
+ const attempts = cleanup.attempts + 1;
405
+ const claimed = await this.records.updateOne({
406
+ moduleId: MODULE_ID,
407
+ collection: "message_cleanup",
408
+ where: { cleanup_id: cleanup.cleanupId },
409
+ expected: { field: "attempts", value: cleanup.attempts },
410
+ values: {
411
+ attempts,
412
+ next_attempt_at: new Date(now.getTime() + Math.min(3_600_000, 30_000 * 2 ** cleanup.attempts)),
413
+ },
414
+ });
415
+ return claimed.updated ? { ...cleanup, attempts } : null;
416
+ }
417
+ async completeCleanup(cleanup, now = new Date()) {
418
+ return (await this.records.updateOne({
419
+ moduleId: MODULE_ID,
420
+ collection: "message_cleanup",
421
+ where: { cleanup_id: cleanup.cleanupId },
422
+ expected: { field: "attempts", value: cleanup.attempts },
423
+ values: { completed_at: now },
424
+ })).updated;
425
+ }
426
+ pendingCleanupCount(panelId) {
427
+ return this.records.count({
428
+ moduleId: MODULE_ID,
429
+ collection: "message_cleanup",
430
+ where: { panel_id: panelId, completed_at: null },
431
+ });
432
+ }
433
+ async #withEntries(panel) {
434
+ const entries = await this.records.findMany({
435
+ moduleId: MODULE_ID,
436
+ collection: "entries",
437
+ select: entrySelect,
438
+ where: { panel_id: requiredString(panel, "panel_id") },
439
+ orderBy: [
440
+ { field: "position", direction: "asc" },
441
+ { field: "entry_id", direction: "asc" },
442
+ ],
443
+ limit: 25,
444
+ });
445
+ return mapPanel(panel, entries.records);
446
+ }
447
+ async #listByEntryField(guildId, field, value, limit) {
448
+ const entries = await this.records.findMany({
449
+ moduleId: MODULE_ID,
450
+ collection: "entries",
451
+ select: ["panel_id"],
452
+ where: { [field]: value },
453
+ orderBy: [
454
+ { field: "position", direction: "asc" },
455
+ { field: "entry_id", direction: "asc" },
456
+ ],
457
+ limit: Math.min(100, Math.max(1, limit)),
458
+ });
459
+ const panels = await Promise.all(entries.records.map((entry) => this.findById(guildId, requiredString(entry, "panel_id"))));
460
+ return panels.filter((panel) => panel !== null);
461
+ }
462
+ }
463
+ function panelValues(input) {
464
+ return {
465
+ panel_id: input.panelId,
466
+ guild_id: input.guildId,
467
+ channel_id: input.draft.channelId,
468
+ ...(input.messageId ? { message_id: input.messageId } : {}),
469
+ title: input.draft.title,
470
+ message_body: input.draft.messageBody,
471
+ message_style: input.draft.messageStyle,
472
+ control_type: input.draft.controlType,
473
+ selection_mode: input.draft.selectionMode,
474
+ persistent_reactions: input.draft.persistentReactions,
475
+ ...(input.draft.embedColour === undefined
476
+ ? {}
477
+ : { embed_colour: input.draft.embedColour }),
478
+ ...(input.draft.dropdownPlaceholder === undefined
479
+ ? {}
480
+ : { dropdown_placeholder: input.draft.dropdownPlaceholder }),
481
+ status: input.status,
482
+ revision: input.revision,
483
+ created_by_user_id: input.actorUserId,
484
+ updated_by_user_id: input.actorUserId,
485
+ created_at: input.now,
486
+ updated_at: input.now,
487
+ };
488
+ }
489
+ function entryValues(panelId, entry, position, now) {
490
+ return {
491
+ entry_id: entry.entryId,
492
+ panel_id: panelId,
493
+ position,
494
+ role_id: entry.roleId,
495
+ nameplate: entry.nameplate,
496
+ ...(entry.emoji
497
+ ? {
498
+ emoji_kind: entry.emoji.kind,
499
+ emoji_name: entry.emoji.name,
500
+ ...(entry.emoji.id ? { emoji_id: entry.emoji.id } : {}),
501
+ emoji_animated: entry.emoji.animated,
502
+ }
503
+ : {}),
504
+ ...(entry.buttonStyle ? { button_style: entry.buttonStyle } : {}),
505
+ created_at: now,
506
+ updated_at: now,
507
+ };
508
+ }
509
+ function mapPanel(row, entries) {
510
+ return {
511
+ panelId: requiredString(row, "panel_id"),
512
+ guildId: requiredString(row, "guild_id"),
513
+ channelId: requiredString(row, "channel_id"),
514
+ messageId: nullableString(row, "message_id"),
515
+ title: requiredString(row, "title"),
516
+ messageBody: requiredString(row, "message_body"),
517
+ messageStyle: requiredEnum(row, "message_style", ["text", "embed"]),
518
+ controlType: requiredEnum(row, "control_type", [
519
+ "buttons",
520
+ "dropdown",
521
+ "reactions",
522
+ ]),
523
+ selectionMode: requiredEnum(row, "selection_mode", [
524
+ "multiple",
525
+ "one_only",
526
+ ]),
527
+ persistentReactions: optionalBoolean(row, "persistent_reactions", true),
528
+ ...(row.embed_colour === null
529
+ ? {}
530
+ : { embedColour: requiredNumber(row, "embed_colour") }),
531
+ ...(row.dropdown_placeholder === null
532
+ ? {}
533
+ : {
534
+ dropdownPlaceholder: requiredString(row, "dropdown_placeholder"),
535
+ }),
536
+ entries: entries.map((entry) => {
537
+ const emojiKind = nullableString(entry, "emoji_kind");
538
+ const emojiName = nullableString(entry, "emoji_name");
539
+ const emojiId = nullableString(entry, "emoji_id");
540
+ const emoji = emojiKind && emojiName
541
+ ? {
542
+ kind: requiredLiteral(emojiKind, ["unicode", "custom"]),
543
+ name: emojiName,
544
+ ...(emojiId ? { id: emojiId } : {}),
545
+ animated: entry.emoji_animated === true,
546
+ identifier: emojiId ? `${emojiName}:${emojiId}` : emojiName,
547
+ key: emojiId ? `custom:${emojiId}` : `unicode:${emojiName}`,
548
+ }
549
+ : null;
550
+ return {
551
+ entryId: requiredString(entry, "entry_id"),
552
+ roleId: requiredString(entry, "role_id"),
553
+ nameplate: requiredString(entry, "nameplate"),
554
+ ...(emoji ? { emoji: emoji.identifier } : {}),
555
+ ...(entry.button_style
556
+ ? {
557
+ buttonStyle: requiredEnum(entry, "button_style", [
558
+ "primary",
559
+ "secondary",
560
+ "success",
561
+ "danger",
562
+ ]),
563
+ }
564
+ : {}),
565
+ };
566
+ }),
567
+ status: requiredEnum(row, "status", [
568
+ "pending",
569
+ "active",
570
+ "disabled",
571
+ "message_missing",
572
+ "needs_attention",
573
+ "cleanup_pending",
574
+ "deleting",
575
+ ]),
576
+ revision: requiredNumber(row, "revision"),
577
+ createdByUserId: nullableString(row, "created_by_user_id"),
578
+ updatedByUserId: nullableString(row, "updated_by_user_id"),
579
+ createdAt: requiredDate(row, "created_at"),
580
+ updatedAt: requiredDate(row, "updated_at"),
581
+ };
582
+ }
583
+ function requiredString(row, field) {
584
+ const value = row[field];
585
+ if (typeof value !== "string" || value.length === 0)
586
+ throw new Error(`Invalid Reaction Roles record field: ${field}`);
587
+ return value;
588
+ }
589
+ function nullableString(row, field) {
590
+ const value = row[field];
591
+ if (value === null || value === undefined)
592
+ return null;
593
+ return requiredString(row, field);
594
+ }
595
+ function requiredNumber(row, field) {
596
+ const value = Number(row[field]);
597
+ if (!Number.isSafeInteger(value))
598
+ throw new Error(`Invalid Reaction Roles record field: ${field}`);
599
+ return value;
600
+ }
601
+ function optionalBoolean(row, field, fallback) {
602
+ const value = row[field];
603
+ if (value === null || value === undefined)
604
+ return fallback;
605
+ if (typeof value !== "boolean")
606
+ throw new Error(`Invalid Reaction Roles record field: ${field}`);
607
+ return value;
608
+ }
609
+ function requiredDate(row, field) {
610
+ const value = row[field];
611
+ const date = value instanceof Date ? value : new Date(String(value));
612
+ if (Number.isNaN(date.getTime()))
613
+ throw new Error(`Invalid Reaction Roles record field: ${field}`);
614
+ return date;
615
+ }
616
+ function requiredEnum(row, field, values) {
617
+ return requiredLiteral(requiredString(row, field), values);
618
+ }
619
+ function requiredLiteral(value, values) {
620
+ if (!values.includes(value))
621
+ throw new Error("Invalid Reaction Roles record enum");
622
+ return value;
623
+ }