@helyx/module-reactionroles 1.0.14 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -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 +9 -2
- package/package.json +3 -3
|
@@ -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
|
|
13
|
+
throw new PanelDraftValidationError("Choose Text or Embed for the message style");
|
|
8
14
|
if (!["buttons", "dropdown", "reactions"].includes(input.controlType))
|
|
9
|
-
throw new
|
|
15
|
+
throw new PanelDraftValidationError("Choose Buttons, Dropdown or Emoji reactions");
|
|
10
16
|
if (!["multiple", "one_only"].includes(input.selectionMode))
|
|
11
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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",
|
package/dist/health.d.ts
ADDED
|
@@ -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";
|