@helyx/module-reactionroles 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +37 -53
  3. package/dist/builder-interactions.d.ts +11 -0
  4. package/dist/builder-interactions.js +293 -0
  5. package/dist/builder-presentation.d.ts +30 -0
  6. package/dist/builder-presentation.js +343 -0
  7. package/dist/builder.d.ts +3 -0
  8. package/dist/builder.js +22 -16
  9. package/dist/cleanup-repository.d.ts +24 -0
  10. package/dist/cleanup-repository.js +118 -0
  11. package/dist/constants.d.ts +3 -0
  12. package/dist/constants.js +4 -1
  13. package/dist/health.d.ts +16 -0
  14. package/dist/health.js +85 -0
  15. package/dist/index.js +66 -1147
  16. package/dist/logging.d.ts +33 -0
  17. package/dist/logging.js +40 -0
  18. package/dist/managed-resource.d.ts +2 -0
  19. package/dist/managed-resource.js +72 -0
  20. package/dist/member-interactions.d.ts +27 -0
  21. package/dist/member-interactions.js +360 -0
  22. package/dist/module-lifecycle.d.ts +3 -0
  23. package/dist/module-lifecycle.js +48 -0
  24. package/dist/panel-cache.d.ts +30 -0
  25. package/dist/panel-cache.js +197 -0
  26. package/dist/panel-create.d.ts +10 -0
  27. package/dist/panel-create.js +80 -0
  28. package/dist/panel-lifecycle.d.ts +47 -0
  29. package/dist/panel-lifecycle.js +272 -0
  30. package/dist/panel-managed.d.ts +14 -0
  31. package/dist/panel-managed.js +27 -0
  32. package/dist/panel-service.d.ts +13 -36
  33. package/dist/panel-service.js +48 -722
  34. package/dist/panel-support.d.ts +35 -0
  35. package/dist/panel-support.js +291 -0
  36. package/dist/permissions.d.ts +2 -0
  37. package/dist/permissions.js +8 -0
  38. package/dist/privacy.d.ts +5 -0
  39. package/dist/privacy.js +12 -0
  40. package/dist/record-mapping.d.ts +28 -0
  41. package/dist/record-mapping.js +161 -0
  42. package/dist/record-schema.d.ts +203 -0
  43. package/dist/record-schema.js +137 -0
  44. package/dist/records.d.ts +9 -201
  45. package/dist/records.js +32 -383
  46. package/dist/runtime-state.d.ts +9 -0
  47. package/dist/runtime-state.js +30 -0
  48. package/manifest.json +1 -1
  49. package/package.json +2 -2
@@ -0,0 +1,343 @@
1
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
+ import { COMPONENT_IDS, PANEL_LIMITS } from "./constants.js";
3
+ import { canonicaliseEmoji, panelMessageDraft, validatePanelDraft, } from "./builder.js";
4
+ function isObject(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ function stringValue(value) {
8
+ if (typeof value !== "string")
9
+ throw new Error("invalid string");
10
+ return value;
11
+ }
12
+ export function basicModal(panel, editing = false) {
13
+ return {
14
+ customId: COMPONENT_IDS.basicModal,
15
+ title: editing ? "Edit role panel" : "Create role panel",
16
+ fields: [
17
+ {
18
+ type: "discord-channel",
19
+ customId: "channel",
20
+ label: "Destination channel",
21
+ required: true,
22
+ channelTypes: ["guild_text", "guild_announcement"],
23
+ ...(panel?.channelId ? { defaultValues: [panel.channelId] } : {}),
24
+ },
25
+ {
26
+ customId: "title",
27
+ label: "Title",
28
+ style: "short",
29
+ required: true,
30
+ minLength: 1,
31
+ maxLength: 80,
32
+ ...(panel?.title ? { value: panel.title } : {}),
33
+ },
34
+ {
35
+ customId: "message",
36
+ label: "Message",
37
+ style: "paragraph",
38
+ required: true,
39
+ minLength: 1,
40
+ maxLength: 1_000,
41
+ ...(panel?.messageBody ? { value: panel.messageBody } : {}),
42
+ },
43
+ {
44
+ type: "string-select",
45
+ customId: "style",
46
+ label: "Message style",
47
+ required: true,
48
+ options: [
49
+ {
50
+ label: "Text",
51
+ value: "text",
52
+ default: panel?.messageStyle !== "embed",
53
+ },
54
+ {
55
+ label: "Embed",
56
+ value: "embed",
57
+ default: panel?.messageStyle === "embed",
58
+ },
59
+ ],
60
+ },
61
+ {
62
+ type: "string-select",
63
+ customId: "control",
64
+ label: "Role controls",
65
+ required: true,
66
+ options: [
67
+ {
68
+ label: "Buttons",
69
+ value: "buttons",
70
+ default: !panel?.controlType || panel.controlType === "buttons",
71
+ },
72
+ {
73
+ label: "Dropdown",
74
+ value: "dropdown",
75
+ default: panel?.controlType === "dropdown",
76
+ },
77
+ {
78
+ label: "Emoji reactions",
79
+ value: "reactions",
80
+ default: panel?.controlType === "reactions",
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ export function defaultsFromSessionState(state) {
88
+ return {
89
+ ...(typeof state.channelId === "string"
90
+ ? { channelId: state.channelId }
91
+ : {}),
92
+ ...(state.messageStyle === "text" || state.messageStyle === "embed"
93
+ ? { messageStyle: state.messageStyle }
94
+ : {}),
95
+ ...(state.controlType === "buttons" ||
96
+ state.controlType === "dropdown" ||
97
+ state.controlType === "reactions"
98
+ ? { controlType: state.controlType }
99
+ : {}),
100
+ };
101
+ }
102
+ export function managedObject(value) {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+ export function entryModal(entry, editing = false) {
106
+ return {
107
+ customId: COMPONENT_IDS.entryModal,
108
+ title: editing ? "Edit role entry" : "Add a role entry",
109
+ fields: [
110
+ {
111
+ type: "discord-role",
112
+ customId: "role",
113
+ label: "Discord role",
114
+ required: true,
115
+ ...(entry ? { defaultValues: [entry.roleId] } : {}),
116
+ },
117
+ {
118
+ customId: "nameplate",
119
+ label: "Public label",
120
+ style: "short",
121
+ required: true,
122
+ minLength: 1,
123
+ maxLength: 80,
124
+ ...(entry ? { value: entry.nameplate } : {}),
125
+ },
126
+ {
127
+ customId: "emoji",
128
+ label: "Emoji",
129
+ description: "Optional for buttons and dropdowns; required for reactions.",
130
+ style: "short",
131
+ required: false,
132
+ minLength: 1,
133
+ maxLength: 100,
134
+ ...(entry?.emoji ? { value: entry.emoji } : {}),
135
+ placeholder: "✅ or <:updates:123456789012345678>",
136
+ },
137
+ {
138
+ type: "string-select",
139
+ customId: "button-style",
140
+ label: "Button colour",
141
+ required: false,
142
+ options: [
143
+ { label: "Grey", value: "secondary", default: true },
144
+ { label: "Blue", value: "primary" },
145
+ { label: "Green", value: "success" },
146
+ { label: "Red", value: "danger" },
147
+ ],
148
+ },
149
+ ...(editing
150
+ ? [
151
+ {
152
+ type: "string-select",
153
+ customId: "entry-action",
154
+ label: "Entry action",
155
+ required: true,
156
+ options: [
157
+ { label: "Save changes", value: "save", default: true },
158
+ { label: "Move up", value: "move-up" },
159
+ { label: "Move down", value: "move-down" },
160
+ { label: "Remove entry", value: "remove" },
161
+ ],
162
+ },
163
+ ]
164
+ : []),
165
+ ],
166
+ };
167
+ }
168
+ export function builderResponse(draft, services) {
169
+ const builder = services.get(HELYX_SERVICE_NAMES.messageBuilder);
170
+ const panel = draft.entries.length > 0
171
+ ? builder.build(panelMessageDraft(draft)).response
172
+ : builder.build({
173
+ presentation: draft.messageStyle === "embed"
174
+ ? {
175
+ type: "embed",
176
+ title: draft.title,
177
+ body: draft.messageBody,
178
+ ...(draft.embedColour === undefined
179
+ ? {}
180
+ : { color: draft.embedColour }),
181
+ }
182
+ : {
183
+ type: "text",
184
+ title: draft.title,
185
+ body: draft.messageBody,
186
+ accentColor: 0x7c3aed,
187
+ },
188
+ allowedUserMentionIds: [],
189
+ }).response;
190
+ return {
191
+ ...panel,
192
+ ephemeral: true,
193
+ selects: draft.entries.length === 0
194
+ ? []
195
+ : [
196
+ {
197
+ type: "string",
198
+ customId: COMPONENT_IDS.builderEntries,
199
+ placeholder: "Edit, reorder or remove a role",
200
+ minValues: 1,
201
+ maxValues: 1,
202
+ options: draft.entries.map((entry) => ({
203
+ label: entry.nameplate,
204
+ value: entry.entryId,
205
+ ...(entry.emoji
206
+ ? {
207
+ emojiIdentifier: canonicaliseEmoji(entry.emoji)
208
+ .identifier,
209
+ }
210
+ : {}),
211
+ })),
212
+ },
213
+ ],
214
+ buttons: [
215
+ {
216
+ type: "button",
217
+ customId: COMPONENT_IDS.addEntry,
218
+ label: "Add role",
219
+ style: "secondary",
220
+ disabled: draft.entries.length >= PANEL_LIMITS[draft.controlType],
221
+ },
222
+ {
223
+ type: "button",
224
+ customId: COMPONENT_IDS.publish,
225
+ label: "Publish panel",
226
+ style: "success",
227
+ disabled: draft.entries.length === 0,
228
+ },
229
+ {
230
+ type: "button",
231
+ customId: COMPONENT_IDS.cancel,
232
+ label: "Cancel",
233
+ style: "secondary",
234
+ },
235
+ ],
236
+ allowedUserMentionIds: [],
237
+ };
238
+ }
239
+ export function interactionMutationContext(context, guildId) {
240
+ return {
241
+ guildId,
242
+ actorUserId: context.userId,
243
+ source: "discord",
244
+ services: context.services,
245
+ correlationId: context.interactionId,
246
+ sendToChannel: (channelId, response) => context.sendToChannel(channelId, response),
247
+ deleteChannelMessage: (channelId, messageId) => context.deleteChannelMessage(channelId, messageId),
248
+ editChannelMessage: (channelId, messageId, response) => context.editChannelMessage(channelId, messageId, response),
249
+ addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => context.addChannelMessageReaction(channelId, messageId, emojiIdentifier),
250
+ };
251
+ }
252
+ export function sessionService(services) {
253
+ return services.get(HELYX_SERVICE_NAMES.sessions);
254
+ }
255
+ export function requireGuild(context) {
256
+ if (!context.serverId)
257
+ throw new Error("Use this command in a Discord server");
258
+ return context.serverId;
259
+ }
260
+ export function expiresAt() {
261
+ return new Date(Date.now() + 20 * 60 * 1_000);
262
+ }
263
+ export function serializeDraft(draft) {
264
+ return {
265
+ channelId: draft.channelId,
266
+ title: draft.title,
267
+ messageBody: draft.messageBody,
268
+ messageStyle: draft.messageStyle,
269
+ controlType: draft.controlType,
270
+ selectionMode: draft.selectionMode,
271
+ persistentReactions: draft.persistentReactions,
272
+ ...(draft.embedColour === undefined
273
+ ? {}
274
+ : { embedColour: draft.embedColour }),
275
+ ...(draft.dropdownPlaceholder === undefined
276
+ ? {}
277
+ : { dropdownPlaceholder: draft.dropdownPlaceholder }),
278
+ entries: draft.entries.map((entry) => ({ ...entry })),
279
+ };
280
+ }
281
+ export function parseSessionDraft(value) {
282
+ if (!isObject(value) || !Array.isArray(value.entries))
283
+ return null;
284
+ try {
285
+ return validatePanelDraft({
286
+ channelId: stringValue(value.channelId),
287
+ title: stringValue(value.title),
288
+ messageBody: stringValue(value.messageBody),
289
+ messageStyle: stringValue(value.messageStyle),
290
+ controlType: stringValue(value.controlType),
291
+ selectionMode: stringValue(value.selectionMode),
292
+ persistentReactions: typeof value.persistentReactions === "boolean"
293
+ ? value.persistentReactions
294
+ : true,
295
+ ...(typeof value.embedColour === "number"
296
+ ? { embedColour: value.embedColour }
297
+ : {}),
298
+ ...(typeof value.dropdownPlaceholder === "string"
299
+ ? { dropdownPlaceholder: value.dropdownPlaceholder }
300
+ : {}),
301
+ entries: value.entries.map((entry) => {
302
+ if (!isObject(entry))
303
+ throw new Error("invalid entry");
304
+ return {
305
+ entryId: stringValue(entry.entryId),
306
+ roleId: stringValue(entry.roleId),
307
+ nameplate: stringValue(entry.nameplate),
308
+ ...(typeof entry.emoji === "string" ? { emoji: entry.emoji } : {}),
309
+ ...(typeof entry.buttonStyle === "string"
310
+ ? { buttonStyle: entry.buttonStyle }
311
+ : {}),
312
+ };
313
+ }),
314
+ }, { allowEmptyEntries: true });
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ export async function replyExpired(context) {
321
+ await context.reply({
322
+ ephemeral: true,
323
+ componentsV2: {
324
+ text: [
325
+ {
326
+ content: "This role-panel builder expired. Run `/reaction-role create` or `/reaction-role edit` again.",
327
+ },
328
+ ],
329
+ },
330
+ });
331
+ }
332
+ export async function stalePanelReply(context) {
333
+ await context.reply({
334
+ ephemeral: true,
335
+ componentsV2: {
336
+ text: [
337
+ {
338
+ content: "This role panel changed or is no longer active. Ask a server manager to repair it.",
339
+ },
340
+ ],
341
+ },
342
+ });
343
+ }
package/dist/builder.d.ts CHANGED
@@ -23,6 +23,9 @@ export interface PanelDraft {
23
23
  dropdownPlaceholder?: string;
24
24
  entries: readonly PanelEntryDraft[];
25
25
  }
26
+ export declare class PanelDraftValidationError extends Error {
27
+ constructor(message: string);
28
+ }
26
29
  export interface CanonicalEmoji {
27
30
  kind: "unicode" | "custom";
28
31
  name: string;
package/dist/builder.js CHANGED
@@ -1,51 +1,57 @@
1
1
  import { COMPONENT_IDS, PANEL_LIMITS } from "./constants.js";
2
+ export class PanelDraftValidationError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "PanelDraftValidationError";
6
+ }
7
+ }
2
8
  export function validatePanelDraft(input, options = {}) {
3
9
  const channelId = snowflake(input.channelId, "Destination channel");
4
10
  const title = boundedText(input.title, "Title", 80);
5
11
  const messageBody = boundedText(input.messageBody, "Message", 1_000);
6
12
  if (!["text", "embed"].includes(input.messageStyle))
7
- throw new Error("Choose Text or Embed for the message style");
13
+ throw new PanelDraftValidationError("Choose Text or Embed for the message style");
8
14
  if (!["buttons", "dropdown", "reactions"].includes(input.controlType))
9
- throw new Error("Choose Buttons, Dropdown or Emoji reactions");
15
+ throw new PanelDraftValidationError("Choose Buttons, Dropdown or Emoji reactions");
10
16
  if (!["multiple", "one_only"].includes(input.selectionMode))
11
- throw new Error("Choose a valid selection mode");
17
+ throw new PanelDraftValidationError("Choose a valid selection mode");
12
18
  const persistentReactions = input.persistentReactions === undefined ? true : input.persistentReactions;
13
19
  if (typeof persistentReactions !== "boolean")
14
- throw new Error("Choose whether member reactions remain on the panel");
20
+ throw new PanelDraftValidationError("Choose whether member reactions remain on the panel");
15
21
  if (input.controlType === "reactions" &&
16
22
  input.selectionMode === "one_only" &&
17
23
  persistentReactions)
18
- throw new Error("Turn off Persistent reactions to use one role only with emoji reactions");
24
+ throw new PanelDraftValidationError("Turn off Persistent reactions to use one role only with emoji reactions");
19
25
  const maximum = PANEL_LIMITS[input.controlType];
20
26
  const minimum = options.allowEmptyEntries === true ? 0 : 1;
21
27
  if (input.entries.length < minimum || input.entries.length > maximum)
22
- throw new Error(`${controlLabel(input.controlType)} panels require between 1 and ${maximum} role entries`);
28
+ throw new PanelDraftValidationError(`${controlLabel(input.controlType)} panels require between 1 and ${maximum} role entries`);
23
29
  const roleIds = new Set();
24
30
  const emojiKeys = new Set();
25
31
  const entryIds = new Set();
26
32
  const entries = input.entries.map((entry) => {
27
33
  const entryId = boundedIdentifier(entry.entryId, "Entry");
28
34
  if (entryIds.has(entryId))
29
- throw new Error("Role entries must be unique");
35
+ throw new PanelDraftValidationError("Role entries must be unique");
30
36
  entryIds.add(entryId);
31
37
  const roleId = snowflake(entry.roleId, "Role");
32
38
  if (roleIds.has(roleId))
33
- throw new Error("Each Discord role can appear only once");
39
+ throw new PanelDraftValidationError("Each Discord role can appear only once");
34
40
  roleIds.add(roleId);
35
41
  const nameplate = boundedText(entry.nameplate, "Role label", 80);
36
42
  const emoji = entry.emoji?.trim()
37
43
  ? canonicaliseEmoji(entry.emoji)
38
44
  : undefined;
39
45
  if (input.controlType === "reactions" && !emoji)
40
- throw new Error("Every emoji-reaction entry needs an emoji");
46
+ throw new PanelDraftValidationError("Every emoji-reaction entry needs an emoji");
41
47
  if (emoji && input.controlType === "reactions") {
42
48
  if (emojiKeys.has(emoji.key))
43
- throw new Error("Each reaction emoji can appear only once");
49
+ throw new PanelDraftValidationError("Each reaction emoji can appear only once");
44
50
  emojiKeys.add(emoji.key);
45
51
  }
46
52
  const buttonStyle = entry.buttonStyle ?? "secondary";
47
53
  if (!["primary", "secondary", "success", "danger"].includes(buttonStyle))
48
- throw new Error("Choose a valid button colour");
54
+ throw new PanelDraftValidationError("Choose a valid button colour");
49
55
  return {
50
56
  entryId,
51
57
  roleId,
@@ -58,7 +64,7 @@ export function validatePanelDraft(input, options = {}) {
58
64
  if (!Number.isInteger(embedColour) ||
59
65
  embedColour < 0 ||
60
66
  embedColour > 0xffffff)
61
- throw new Error("Embed colour must be between 0 and 16777215");
67
+ throw new PanelDraftValidationError("Embed colour must be between 0 and 16777215");
62
68
  const dropdownPlaceholder = input.dropdownPlaceholder?.trim()
63
69
  ? boundedText(input.dropdownPlaceholder, "Dropdown label", 100)
64
70
  : "Choose your roles";
@@ -170,7 +176,7 @@ export function canonicaliseEmoji(input) {
170
176
  if (value.length === 0 ||
171
177
  value.length > 32 ||
172
178
  /^[\p{L}\p{N}\s.,!?_-]+$/u.test(value))
173
- throw new Error("Enter one Unicode emoji or a custom server emoji");
179
+ throw new PanelDraftValidationError("Enter one Unicode emoji or a custom server emoji");
174
180
  return {
175
181
  kind: "unicode",
176
182
  name: value,
@@ -182,19 +188,19 @@ export function canonicaliseEmoji(input) {
182
188
  function boundedText(input, label, maximum) {
183
189
  const value = input.trim().normalize("NFC");
184
190
  if (value.length < 1 || value.length > maximum)
185
- throw new Error(`${label} must be between 1 and ${maximum} characters`);
191
+ throw new PanelDraftValidationError(`${label} must be between 1 and ${maximum} characters`);
186
192
  return value;
187
193
  }
188
194
  function snowflake(input, label) {
189
195
  const value = input.trim();
190
196
  if (!/^\d{17,20}$/u.test(value))
191
- throw new Error(`${label} is not a valid Discord resource`);
197
+ throw new PanelDraftValidationError(`${label} is not a valid Discord resource`);
192
198
  return value;
193
199
  }
194
200
  function boundedIdentifier(input, label) {
195
201
  const value = input.trim();
196
202
  if (!/^[A-Za-z0-9_-]{1,100}$/u.test(value))
197
- throw new Error(`${label} identifier is invalid`);
203
+ throw new PanelDraftValidationError(`${label} identifier is invalid`);
198
204
  return value;
199
205
  }
200
206
  function controlLabel(type) {
@@ -0,0 +1,24 @@
1
+ import type { ModuleRecordService, ModuleRecordWriteValue } from "@helyx/sdk";
2
+ import { type PendingMessageCleanup } from "./record-schema.js";
3
+ export declare class MessageCleanupRepository {
4
+ private readonly records;
5
+ constructor(records: ModuleRecordService);
6
+ record(input: {
7
+ guildId: string;
8
+ panelId: string;
9
+ channelId: string;
10
+ messageId: string;
11
+ reason: "replacement" | "rollback" | "delete";
12
+ now?: Date;
13
+ }): Promise<boolean>;
14
+ list(limit?: number): Promise<readonly PendingMessageCleanup[]>;
15
+ listPage(limit?: number, cursor?: readonly ModuleRecordWriteValue[]): Promise<{
16
+ items: readonly PendingMessageCleanup[];
17
+ nextCursor: readonly ModuleRecordWriteValue[] | null;
18
+ }>;
19
+ find(cleanupId: string): Promise<PendingMessageCleanup | null>;
20
+ claim(cleanup: PendingMessageCleanup, now?: Date): Promise<PendingMessageCleanup | null>;
21
+ complete(cleanup: PendingMessageCleanup, now?: Date): Promise<boolean>;
22
+ count(panelId: string): Promise<number>;
23
+ private parse;
24
+ }
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { REACTION_ROLES_MODULE_ID, REACTION_ROLE_CLEANUP_TASK, } from "./constants.js";
3
+ import { requiredDate, requiredEnum, requiredNumber, requiredString, } from "./record-mapping.js";
4
+ import { cleanupSelect } from "./record-schema.js";
5
+ export class MessageCleanupRepository {
6
+ records;
7
+ constructor(records) {
8
+ this.records = records;
9
+ }
10
+ async record(input) {
11
+ const cleanupId = randomUUID();
12
+ const now = input.now ?? new Date();
13
+ const created = await this.records.appendUnique({
14
+ moduleId: REACTION_ROLES_MODULE_ID,
15
+ collection: "message_cleanup",
16
+ values: {
17
+ cleanup_id: cleanupId,
18
+ guild_id: input.guildId,
19
+ panel_id: input.panelId,
20
+ channel_id: input.channelId,
21
+ message_id: input.messageId,
22
+ reason: input.reason,
23
+ attempts: 0,
24
+ next_attempt_at: now,
25
+ created_at: now,
26
+ },
27
+ uniqueBy: ["guild_id", "channel_id", "message_id", "reason"],
28
+ schedule: {
29
+ taskKind: REACTION_ROLE_CLEANUP_TASK,
30
+ guildId: input.guildId,
31
+ idempotencyKey: `reactionroles:cleanup:${input.guildId}:${input.channelId}:${input.messageId}:${input.reason}`,
32
+ payload: { cleanupId },
33
+ scheduledFor: now,
34
+ },
35
+ });
36
+ return created.created;
37
+ }
38
+ async list(limit = 100) {
39
+ return (await this.listPage(limit)).items;
40
+ }
41
+ async listPage(limit = 100, cursor) {
42
+ const page = await this.records.findMany({
43
+ moduleId: REACTION_ROLES_MODULE_ID,
44
+ collection: "message_cleanup",
45
+ select: cleanupSelect,
46
+ where: { completed_at: null },
47
+ orderBy: [
48
+ { field: "next_attempt_at", direction: "asc" },
49
+ { field: "cleanup_id", direction: "asc" },
50
+ ],
51
+ ...(cursor ? { cursor } : {}),
52
+ limit: Math.min(100, Math.max(1, limit)),
53
+ });
54
+ return {
55
+ items: page.records.map((row) => this.parse(row)),
56
+ nextCursor: page.nextCursor,
57
+ };
58
+ }
59
+ async find(cleanupId) {
60
+ const row = await this.records.findOne({
61
+ moduleId: REACTION_ROLES_MODULE_ID,
62
+ collection: "message_cleanup",
63
+ select: cleanupSelect,
64
+ where: { cleanup_id: cleanupId, completed_at: null },
65
+ });
66
+ return row ? this.parse(row) : null;
67
+ }
68
+ async claim(cleanup, now = new Date()) {
69
+ if (cleanup.attempts >= 10 ||
70
+ cleanup.nextAttemptAt.getTime() > now.getTime())
71
+ return null;
72
+ const attempts = cleanup.attempts + 1;
73
+ const nextAttemptAt = new Date(now.getTime() + Math.min(3_600_000, 30_000 * 2 ** cleanup.attempts));
74
+ const claimed = await this.records.updateOne({
75
+ moduleId: REACTION_ROLES_MODULE_ID,
76
+ collection: "message_cleanup",
77
+ where: { cleanup_id: cleanup.cleanupId },
78
+ expected: { field: "attempts", value: cleanup.attempts },
79
+ values: {
80
+ attempts,
81
+ next_attempt_at: nextAttemptAt,
82
+ },
83
+ });
84
+ return claimed.updated ? { ...cleanup, attempts, nextAttemptAt } : null;
85
+ }
86
+ async complete(cleanup, now = new Date()) {
87
+ return (await this.records.updateOne({
88
+ moduleId: REACTION_ROLES_MODULE_ID,
89
+ collection: "message_cleanup",
90
+ where: { cleanup_id: cleanup.cleanupId },
91
+ expected: { field: "attempts", value: cleanup.attempts },
92
+ values: { completed_at: now },
93
+ })).updated;
94
+ }
95
+ count(panelId) {
96
+ return this.records.count({
97
+ moduleId: REACTION_ROLES_MODULE_ID,
98
+ collection: "message_cleanup",
99
+ where: { panel_id: panelId, completed_at: null },
100
+ });
101
+ }
102
+ parse(row) {
103
+ return {
104
+ cleanupId: requiredString(row, "cleanup_id"),
105
+ guildId: requiredString(row, "guild_id"),
106
+ panelId: requiredString(row, "panel_id"),
107
+ channelId: requiredString(row, "channel_id"),
108
+ messageId: requiredString(row, "message_id"),
109
+ reason: requiredEnum(row, "reason", [
110
+ "replacement",
111
+ "rollback",
112
+ "delete",
113
+ ]),
114
+ attempts: requiredNumber(row, "attempts"),
115
+ nextAttemptAt: requiredDate(row, "next_attempt_at"),
116
+ };
117
+ }
118
+ }
@@ -1,5 +1,8 @@
1
+ export declare const REACTION_ROLES_MODULE_ID = "helyx.reactionroles";
2
+ /** @deprecated Use the feature-specific canonical identity export. */
1
3
  export declare const MODULE_ID = "helyx.reactionroles";
2
4
  export declare const PANEL_PURPOSE = "helyx.reactionroles.builder";
5
+ export declare const REACTION_ROLE_CLEANUP_TASK = "cleanup-owned-message";
3
6
  export declare const COMPONENT_IDS: {
4
7
  readonly addEntry: "helyx.reactionroles.builder.add-entry";
5
8
  readonly continueDraft: "helyx.reactionroles.builder.continue";
package/dist/constants.js CHANGED
@@ -1,5 +1,8 @@
1
- export const MODULE_ID = "helyx.reactionroles";
1
+ export const REACTION_ROLES_MODULE_ID = "helyx.reactionroles";
2
+ /** @deprecated Use the feature-specific canonical identity export. */
3
+ export const MODULE_ID = REACTION_ROLES_MODULE_ID;
2
4
  export const PANEL_PURPOSE = "helyx.reactionroles.builder";
5
+ export const REACTION_ROLE_CLEANUP_TASK = "cleanup-owned-message";
3
6
  export const COMPONENT_IDS = {
4
7
  addEntry: "helyx.reactionroles.builder.add-entry",
5
8
  continueDraft: "helyx.reactionroles.builder.continue",
@@ -0,0 +1,16 @@
1
+ import { type HealthResult, type RuntimeContext } from "@helyx/sdk";
2
+ export declare class ReactionRolesHealthTracker {
3
+ #private;
4
+ start(context: RuntimeContext): void;
5
+ startupResult(input: {
6
+ cacheWarmFailed?: boolean;
7
+ cacheSaturated?: boolean;
8
+ cleanupFailures?: number;
9
+ }): void;
10
+ primarySuccess(source?: ReactionRolesHealthSource): void;
11
+ primaryFailure(source?: ReactionRolesHealthSource): void;
12
+ cleanupRecovered(): void;
13
+ stop(): void;
14
+ health(): HealthResult;
15
+ }
16
+ export type ReactionRolesHealthSource = "resource" | "member" | "lifecycle";