@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @helyx/module-reactionroles
2
2
 
3
+ ## 1.0.17
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [bbc4eee]
8
+ - @helyx/sdk@0.10.0
9
+
10
+ ## 1.0.16
11
+
12
+ ### Patch Changes
13
+
14
+ - Add declared command-operation permission targets, project them through both permission-management surfaces, and harden module lifecycle, health, visibility, bounded-work, optional-side-effect, command-permission, and Forms durable-workflow behavior. Preserve existing FAQ, Access Control, Achievements, Giveaways and Levels overrides through additive migrations while assigning every slash-command path its own permission identity.
15
+
3
16
  ## 1.0.15
4
17
 
5
18
  ### Patch Changes
package/README.md CHANGED
@@ -1,53 +1,37 @@
1
- # Reaction Roles module
2
-
3
- Reaction Roles lets server managers publish Helyx-owned panels from Discord or
4
- the dashboard. Members can select only the approved roles on each panel using
5
- buttons, a dropdown or emoji reactions.
6
-
7
- Reaction messages include an automatically generated emoji-to-role key.
8
- Persistent reactions are enabled by default, so adding and removing a reaction
9
- synchronises the mapped role. Managers can turn persistence off so Helyx
10
- assigns the role and removes only the member's reaction. Non-persistent panels
11
- also support one-role-only reconciliation.
12
-
13
- The module validates role ownership, hierarchy and dangerous permissions before
14
- publication and again before a role changes. Discord remains the source of
15
- truth for membership: Helyx does not store member selections, click history or
16
- reaction history.
17
-
18
- ## Permissions
19
-
20
- Helyx needs View Channel, Send Messages and Manage Roles. Embed panels also
21
- need Embed Links. Emoji-reaction panels need Read Message History and Add
22
- Reactions. Non-persistent emoji panels additionally need Manage Messages so
23
- Helyx can remove the reacting member's reaction. The module never requests
24
- Administrator or Message Content.
25
-
26
- ## Stored data
27
-
28
- Helyx stores panel and entry configuration, the Helyx-owned Discord message
29
- reference, lifecycle status, revision and privacy-detachable creator/updater
30
- IDs. It does not store lists of members holding a role.
31
-
32
- This package remains in Testing until its automated and controlled Discord
33
- acceptance gates have passed.
34
-
35
- ## Operator runbook
36
-
37
- Before Beta, enable the Testing package in one dedicated guild and apply its
38
- guarded migration through the normal Helyx migration runner. Confirm module
39
- health is healthy, create one panel of each control type through both the
40
- dashboard and Discord, restart the shard worker, and verify the panels still
41
- respond.
42
-
43
- If a role, channel, emoji or owned message is removed, the panel should become
44
- `needs_attention` or `message_missing`. Correct the Discord resource and use
45
- the module's Repair or Repost action. A `cleanup_pending` panel remains safe to
46
- use while Helyx retries deletion of the old owned message. Repeated or
47
- abandoned cleanup failures are an operator alert and must be investigated
48
- before promotion.
49
-
50
- Disabling the module stops its interactions without deleting configuration.
51
- Re-enable only after current channel permissions, role hierarchy and emoji
52
- availability have been checked. Rollback may restore compatible application
53
- code, but must not reverse the migration or delete panel data.
1
+ # @helyx/module-reactionroles
2
+
3
+ Reaction Roles publishes Helyx-owned panels that let members assign approved Discord roles to themselves.
4
+
5
+ ## Availability and deployment
6
+
7
+ Reaction Roles is a **Live** module for Helyx-hosted and self-hosted deployments. Its migrations are additive and must be applied through the normal Helyx migration runner before the module starts.
8
+
9
+ ## What it does
10
+
11
+ Server managers create button, dropdown, or emoji-reaction panels through the dashboard. Panels support multiple-role and one-role-only selection, text or embed messages, persistent reactions by default, generated emoji-to-role keys, repair, repost, duplicate, and deletion workflows. Discord remains the source of truth for membership.
12
+
13
+ ## Commands and interactions
14
+
15
+ The module has no slash-command root. Managers use the dashboard resource manager. Members use the published buttons, dropdowns, or reactions. The stable manager policy identifiers are `reactionroles.create`, `reactionroles.manage`, and `reactionroles.delete`.
16
+
17
+ ## Dashboard configuration
18
+
19
+ The `panels` resource supports list, read, preview, create, update, duplicate, repair, repost, and delete operations. Publication validates channel access, role hierarchy, dangerous role permissions, custom emoji availability, entry limits, and optimistic revisions before changing Discord or stored state.
20
+
21
+ ## Discord requirements
22
+
23
+ Helyx needs View Channel, Send Messages, and Manage Roles. Embed panels also need Embed Links. Reaction panels need Read Message History and Add Reactions. Non-persistent reaction panels need Manage Messages so Helyx can remove the member reaction after assigning the role. The module does not request Administrator or Message Content.
24
+
25
+ ## Data, privacy and retention
26
+
27
+ Helyx stores panel and entry configuration, Helyx-owned message references, lifecycle state, revision, cleanup work, and privacy-detachable creator and updater IDs. It does not store member selections, role membership history, click history, or reaction history. Privacy access and erasure remain available while the module is disabled.
28
+
29
+ ## Operations and failure behavior
30
+
31
+ Every owned-message cleanup record atomically schedules a bounded one-shot retry through the core scheduler; startup also reconciles paginated pending work before warming the bounded active-panel cache. Exhausted cleanup remains durable for operator review. Unknown message reactions use a short negative cache and coalesced lookups. Deleted or invalid Discord resources move affected panels to `message_missing` or `needs_attention`; repair or repost restores them. Health is unhealthy while stopped, when required services are missing, or after a recent primary operation failure, and degraded for cold-cache startup, cache saturation, or pending cleanup failure.
32
+
33
+ Disablement stops new interactions without deleting configuration. Re-enable only after checking channel access, role hierarchy, and emoji availability. Rollback may restore compatible application code, but must not reverse migrations or delete panel data.
34
+
35
+ ## Documentation and support
36
+
37
+ User guides, permissions, troubleshooting, privacy details, and dashboard walkthroughs are published in the [Reaction Roles documentation](https://docs.helyx.gg/modules/reaction-roles/). Report defects through the [Helyx issue tracker](https://github.com/ZyC0R3/Helyx/issues).
@@ -0,0 +1,11 @@
1
+ import type { ButtonContribution, ModalInteractionContext, SelectInteractionContext } from "@helyx/sdk";
2
+ export declare function continueDraft(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
3
+ export declare function replaceDraft(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
4
+ export declare function keepDraft(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
5
+ export declare function saveBasicDraft(context: ModalInteractionContext): Promise<void>;
6
+ export declare function openEntryModal(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
7
+ export declare function openEditEntryModal(context: SelectInteractionContext): Promise<void>;
8
+ export declare function saveEntry(context: ModalInteractionContext): Promise<void>;
9
+ export declare function publishDraft(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
10
+ export declare function cancelDraft(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
11
+ export declare function confirmDelete(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
@@ -0,0 +1,293 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PANEL_LIMITS, PANEL_PURPOSE } from "./constants.js";
3
+ import { validatePanelDraft, } from "./builder.js";
4
+ import { reactionRolePanels as panels } from "./runtime-state.js";
5
+ import { basicModal, builderResponse, defaultsFromSessionState, entryModal, expiresAt, interactionMutationContext, managedObject, parseSessionDraft, replyExpired, requireGuild, serializeDraft, sessionService, } from "./builder-presentation.js";
6
+ function requiredStyle(values) {
7
+ const value = values[0];
8
+ if (value !== "text" && value !== "embed")
9
+ throw new Error("Choose Text or Embed");
10
+ return value;
11
+ }
12
+ function requiredControl(values) {
13
+ const value = values[0];
14
+ if (value !== "buttons" && value !== "dropdown" && value !== "reactions")
15
+ throw new Error("Choose valid role controls");
16
+ return value;
17
+ }
18
+ export async function continueDraft(context) {
19
+ const guildId = requireGuild(context);
20
+ const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
21
+ if (!session)
22
+ return replyExpired(context);
23
+ const draft = parseSessionDraft(session.state.draft);
24
+ if (draft) {
25
+ await context.reply(builderResponse(draft, context.services));
26
+ return;
27
+ }
28
+ await context.showModal(basicModal(defaultsFromSessionState(session.state)));
29
+ }
30
+ export async function replaceDraft(context) {
31
+ const guildId = requireGuild(context);
32
+ const sessions = sessionService(context.services);
33
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
34
+ if (!session)
35
+ return replyExpired(context);
36
+ const replacement = managedObject(session.state.pendingReplacement)
37
+ ? session.state.pendingReplacement
38
+ : {
39
+ mode: "create",
40
+ selectionMode: "multiple",
41
+ persistentReactions: true,
42
+ };
43
+ await sessions.create({
44
+ guildId,
45
+ userId: context.userId,
46
+ purpose: PANEL_PURPOSE,
47
+ state: { ...replacement },
48
+ expiresAt: expiresAt(),
49
+ replace: true,
50
+ });
51
+ await context.showModal(basicModal(defaultsFromSessionState(replacement)));
52
+ }
53
+ export async function keepDraft(context) {
54
+ const guildId = requireGuild(context);
55
+ const sessions = sessionService(context.services);
56
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
57
+ if (!session)
58
+ return replyExpired(context);
59
+ const state = { ...session.state };
60
+ delete state.pendingReplacement;
61
+ await sessions.update({
62
+ sessionId: session.sessionId,
63
+ state,
64
+ expectedVersion: session.version,
65
+ expiresAt: expiresAt(),
66
+ });
67
+ await context.reply({
68
+ ephemeral: true,
69
+ componentsV2: {
70
+ text: [{ content: "Your existing Reaction Roles draft was kept." }],
71
+ },
72
+ });
73
+ }
74
+ export async function saveBasicDraft(context) {
75
+ const guildId = requireGuild(context);
76
+ const sessions = sessionService(context.services);
77
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
78
+ if (!session)
79
+ return replyExpired(context);
80
+ const prior = parseSessionDraft(session.state.draft);
81
+ const controlType = requiredControl(context.getStringSelectValues("control"));
82
+ const selectionMode = session.state.selectionMode === "one_only"
83
+ ? "one_only"
84
+ : (prior?.selectionMode ?? "multiple");
85
+ const persistentReactions = controlType === "reactions" && selectionMode === "one_only"
86
+ ? false
87
+ : (prior?.persistentReactions ??
88
+ session.state.persistentReactions !== false);
89
+ const draft = {
90
+ channelId: context.getSelectedChannelIds("channel")[0] ?? "",
91
+ title: context.getField("title"),
92
+ messageBody: context.getField("message"),
93
+ messageStyle: requiredStyle(context.getStringSelectValues("style")),
94
+ controlType,
95
+ selectionMode,
96
+ persistentReactions,
97
+ ...(prior?.embedColour === undefined
98
+ ? { embedColour: 0x7c3aed }
99
+ : { embedColour: prior.embedColour }),
100
+ ...(prior?.dropdownPlaceholder
101
+ ? { dropdownPlaceholder: prior.dropdownPlaceholder }
102
+ : {}),
103
+ entries: prior?.entries ?? [],
104
+ };
105
+ await sessions.update({
106
+ sessionId: session.sessionId,
107
+ state: { ...session.state, draft: serializeDraft(draft) },
108
+ expectedVersion: session.version,
109
+ expiresAt: expiresAt(),
110
+ });
111
+ await context.reply(builderResponse(draft, context.services));
112
+ }
113
+ export async function openEntryModal(context) {
114
+ const guildId = requireGuild(context);
115
+ const session = await sessionService(context.services).getFor(guildId, context.userId, PANEL_PURPOSE);
116
+ if (!session)
117
+ return replyExpired(context);
118
+ const draft = parseSessionDraft(session.state.draft);
119
+ if (!draft)
120
+ return replyExpired(context);
121
+ if (draft.entries.length >= PANEL_LIMITS[draft.controlType]) {
122
+ await context.reply({
123
+ ephemeral: true,
124
+ componentsV2: {
125
+ text: [{ content: "This panel already has the maximum role entries." }],
126
+ },
127
+ });
128
+ return;
129
+ }
130
+ await context.showModal(entryModal());
131
+ }
132
+ export async function openEditEntryModal(context) {
133
+ const guildId = requireGuild(context);
134
+ const sessions = sessionService(context.services);
135
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
136
+ if (!session)
137
+ return replyExpired(context);
138
+ const draft = parseSessionDraft(session.state.draft);
139
+ const entryId = context.selectedValues[0];
140
+ const entry = draft?.entries.find((candidate) => candidate.entryId === entryId);
141
+ if (!draft || !entry || context.selectedValues.length !== 1)
142
+ return replyExpired(context);
143
+ await sessions.update({
144
+ sessionId: session.sessionId,
145
+ state: { ...session.state, editingEntryId: entry.entryId },
146
+ expectedVersion: session.version,
147
+ expiresAt: expiresAt(),
148
+ });
149
+ await context.showModal(entryModal(entry, true));
150
+ }
151
+ export async function saveEntry(context) {
152
+ const guildId = requireGuild(context);
153
+ const sessions = sessionService(context.services);
154
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
155
+ if (!session)
156
+ return replyExpired(context);
157
+ const draft = parseSessionDraft(session.state.draft);
158
+ if (!draft)
159
+ return replyExpired(context);
160
+ const editingEntryId = typeof session.state.editingEntryId === "string"
161
+ ? session.state.editingEntryId
162
+ : null;
163
+ const editingIndex = editingEntryId
164
+ ? draft.entries.findIndex(({ entryId }) => entryId === editingEntryId)
165
+ : -1;
166
+ if (editingEntryId && editingIndex < 0)
167
+ return replyExpired(context);
168
+ const action = editingEntryId
169
+ ? (context.getStringSelectValues("entry-action")[0] ?? "save")
170
+ : "save";
171
+ const entries = [...draft.entries];
172
+ if (editingEntryId && action === "remove") {
173
+ entries.splice(editingIndex, 1);
174
+ }
175
+ else if (editingEntryId && action === "move-up") {
176
+ if (editingIndex > 0)
177
+ [entries[editingIndex - 1], entries[editingIndex]] = [
178
+ entries[editingIndex],
179
+ entries[editingIndex - 1],
180
+ ];
181
+ }
182
+ else if (editingEntryId && action === "move-down") {
183
+ if (editingIndex < entries.length - 1)
184
+ [entries[editingIndex], entries[editingIndex + 1]] = [
185
+ entries[editingIndex + 1],
186
+ entries[editingIndex],
187
+ ];
188
+ }
189
+ else {
190
+ const roleId = context.getSelectedRoleIds("role")[0] ?? "";
191
+ const buttonStyle = context.getStringSelectValues("button-style")[0];
192
+ const entry = {
193
+ entryId: editingEntryId ?? `entry-${randomUUID().replaceAll("-", "")}`,
194
+ roleId,
195
+ nameplate: context.getField("nameplate"),
196
+ ...(context.getField("emoji").trim()
197
+ ? { emoji: context.getField("emoji") }
198
+ : {}),
199
+ ...(buttonStyle
200
+ ? {
201
+ buttonStyle: buttonStyle,
202
+ }
203
+ : {}),
204
+ };
205
+ if (editingEntryId)
206
+ entries[editingIndex] = entry;
207
+ else
208
+ entries.push(entry);
209
+ }
210
+ const next = entries.length === 0
211
+ ? { ...draft, entries }
212
+ : validatePanelDraft({
213
+ ...draft,
214
+ entries,
215
+ });
216
+ const state = {
217
+ ...session.state,
218
+ draft: serializeDraft(next),
219
+ };
220
+ delete state.editingEntryId;
221
+ await sessions.update({
222
+ sessionId: session.sessionId,
223
+ state,
224
+ expectedVersion: session.version,
225
+ expiresAt: expiresAt(),
226
+ });
227
+ await context.reply(builderResponse(next, context.services));
228
+ }
229
+ export async function publishDraft(context) {
230
+ const guildId = requireGuild(context);
231
+ const sessions = sessionService(context.services);
232
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
233
+ if (!session)
234
+ return replyExpired(context);
235
+ const draft = parseSessionDraft(session.state.draft);
236
+ if (!draft)
237
+ return replyExpired(context);
238
+ const mutation = interactionMutationContext(context, guildId);
239
+ const panel = session.state.mode === "edit" &&
240
+ typeof session.state.panelId === "string" &&
241
+ typeof session.state.expectedRevision === "number"
242
+ ? await panels.update(mutation, {
243
+ resourceId: session.state.panelId,
244
+ expectedRevision: session.state.expectedRevision,
245
+ draft,
246
+ })
247
+ : await panels.create(mutation, draft);
248
+ await sessions.delete(session.sessionId);
249
+ await context.reply({
250
+ ephemeral: true,
251
+ componentsV2: {
252
+ text: [
253
+ {
254
+ content: `Reaction Roles panel **${panel.title}** is active in <#${panel.channelId}>.`,
255
+ },
256
+ ],
257
+ },
258
+ allowedUserMentionIds: [],
259
+ });
260
+ }
261
+ export async function cancelDraft(context) {
262
+ const guildId = requireGuild(context);
263
+ const sessions = sessionService(context.services);
264
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
265
+ if (session)
266
+ await sessions.delete(session.sessionId);
267
+ await context.reply({
268
+ ephemeral: true,
269
+ componentsV2: { text: [{ content: "Nothing was changed." }] },
270
+ });
271
+ }
272
+ export async function confirmDelete(context) {
273
+ const guildId = requireGuild(context);
274
+ const sessions = sessionService(context.services);
275
+ const session = await sessions.getFor(guildId, context.userId, PANEL_PURPOSE);
276
+ if (!session ||
277
+ session.state.mode !== "delete" ||
278
+ typeof session.state.panelId !== "string" ||
279
+ typeof session.state.expectedRevision !== "number")
280
+ return replyExpired(context);
281
+ await panels.delete(interactionMutationContext(context, guildId), session.state.panelId, session.state.expectedRevision);
282
+ await sessions.delete(session.sessionId);
283
+ await context.reply({
284
+ ephemeral: true,
285
+ componentsV2: {
286
+ text: [
287
+ {
288
+ content: "The role panel was deleted. Existing member roles were not changed.",
289
+ },
290
+ ],
291
+ },
292
+ });
293
+ }
@@ -0,0 +1,30 @@
1
+ import type { InteractionSessionService, MessageResponse, ModalDefinition, ServiceAccess } from "@helyx/sdk";
2
+ import { type PanelDraft } from "./builder.js";
3
+ import type { PanelMutationContext } from "./panel-service.js";
4
+ export declare function basicModal(panel?: Partial<PanelDraft>, editing?: boolean): ModalDefinition;
5
+ export declare function defaultsFromSessionState(state: Readonly<Record<string, unknown>>): Partial<PanelDraft>;
6
+ export declare function managedObject(value: unknown): value is Record<string, unknown>;
7
+ export declare function entryModal(entry?: PanelDraft["entries"][number], editing?: boolean): ModalDefinition;
8
+ export declare function builderResponse(draft: PanelDraft, services: ServiceAccess): MessageResponse;
9
+ export declare function interactionMutationContext(context: {
10
+ userId: string;
11
+ services: ServiceAccess;
12
+ interactionId: string;
13
+ sendToChannel: PanelMutationContext["sendToChannel"];
14
+ deleteChannelMessage: PanelMutationContext["deleteChannelMessage"];
15
+ editChannelMessage: PanelMutationContext["editChannelMessage"];
16
+ addChannelMessageReaction: PanelMutationContext["addChannelMessageReaction"];
17
+ }, guildId: string): PanelMutationContext;
18
+ export declare function sessionService(services: ServiceAccess): InteractionSessionService;
19
+ export declare function requireGuild(context: {
20
+ serverId?: string;
21
+ }): string;
22
+ export declare function expiresAt(): Date;
23
+ export declare function serializeDraft(draft: PanelDraft): Record<string, unknown>;
24
+ export declare function parseSessionDraft(value: unknown): PanelDraft | null;
25
+ export declare function replyExpired(context: {
26
+ reply(response: MessageResponse): Promise<void>;
27
+ }): Promise<void>;
28
+ export declare function stalePanelReply(context: {
29
+ reply(response: MessageResponse): Promise<void>;
30
+ }): Promise<void>;