@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.
package/README.md ADDED
@@ -0,0 +1,53 @@
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.
@@ -0,0 +1,38 @@
1
+ import type { MessageBuilderDraft } from "@helyx/sdk";
2
+ import { PANEL_LIMITS } from "./constants.js";
3
+ export type PanelControlType = keyof typeof PANEL_LIMITS;
4
+ export type PanelMessageStyle = "text" | "embed";
5
+ export type PanelSelectionMode = "multiple" | "one_only";
6
+ export type PanelButtonStyle = "primary" | "secondary" | "success" | "danger";
7
+ export interface PanelEntryDraft {
8
+ entryId: string;
9
+ roleId: string;
10
+ nameplate: string;
11
+ emoji?: string;
12
+ buttonStyle?: PanelButtonStyle;
13
+ }
14
+ export interface PanelDraft {
15
+ channelId: string;
16
+ title: string;
17
+ messageBody: string;
18
+ messageStyle: PanelMessageStyle;
19
+ controlType: PanelControlType;
20
+ selectionMode: PanelSelectionMode;
21
+ persistentReactions: boolean;
22
+ embedColour?: number;
23
+ dropdownPlaceholder?: string;
24
+ entries: readonly PanelEntryDraft[];
25
+ }
26
+ export interface CanonicalEmoji {
27
+ kind: "unicode" | "custom";
28
+ name: string;
29
+ id?: string;
30
+ animated: boolean;
31
+ identifier: string;
32
+ key: string;
33
+ }
34
+ export declare function validatePanelDraft(input: PanelDraft, options?: {
35
+ allowEmptyEntries?: boolean;
36
+ }): PanelDraft;
37
+ export declare function panelMessageDraft(input: PanelDraft): MessageBuilderDraft;
38
+ export declare function canonicaliseEmoji(input: string): CanonicalEmoji;
@@ -0,0 +1,206 @@
1
+ import { COMPONENT_IDS, PANEL_LIMITS } from "./constants.js";
2
+ export function validatePanelDraft(input, options = {}) {
3
+ const channelId = snowflake(input.channelId, "Destination channel");
4
+ const title = boundedText(input.title, "Title", 80);
5
+ const messageBody = boundedText(input.messageBody, "Message", 1_000);
6
+ if (!["text", "embed"].includes(input.messageStyle))
7
+ throw new Error("Choose Text or Embed for the message style");
8
+ if (!["buttons", "dropdown", "reactions"].includes(input.controlType))
9
+ throw new Error("Choose Buttons, Dropdown or Emoji reactions");
10
+ if (!["multiple", "one_only"].includes(input.selectionMode))
11
+ throw new Error("Choose a valid selection mode");
12
+ const persistentReactions = input.persistentReactions === undefined ? true : input.persistentReactions;
13
+ if (typeof persistentReactions !== "boolean")
14
+ throw new Error("Choose whether member reactions remain on the panel");
15
+ if (input.controlType === "reactions" &&
16
+ input.selectionMode === "one_only" &&
17
+ persistentReactions)
18
+ throw new Error("Turn off Persistent reactions to use one role only with emoji reactions");
19
+ const maximum = PANEL_LIMITS[input.controlType];
20
+ const minimum = options.allowEmptyEntries === true ? 0 : 1;
21
+ if (input.entries.length < minimum || input.entries.length > maximum)
22
+ throw new Error(`${controlLabel(input.controlType)} panels require between 1 and ${maximum} role entries`);
23
+ const roleIds = new Set();
24
+ const emojiKeys = new Set();
25
+ const entryIds = new Set();
26
+ const entries = input.entries.map((entry) => {
27
+ const entryId = boundedIdentifier(entry.entryId, "Entry");
28
+ if (entryIds.has(entryId))
29
+ throw new Error("Role entries must be unique");
30
+ entryIds.add(entryId);
31
+ const roleId = snowflake(entry.roleId, "Role");
32
+ if (roleIds.has(roleId))
33
+ throw new Error("Each Discord role can appear only once");
34
+ roleIds.add(roleId);
35
+ const nameplate = boundedText(entry.nameplate, "Role label", 80);
36
+ const emoji = entry.emoji?.trim()
37
+ ? canonicaliseEmoji(entry.emoji)
38
+ : undefined;
39
+ if (input.controlType === "reactions" && !emoji)
40
+ throw new Error("Every emoji-reaction entry needs an emoji");
41
+ if (emoji && input.controlType === "reactions") {
42
+ if (emojiKeys.has(emoji.key))
43
+ throw new Error("Each reaction emoji can appear only once");
44
+ emojiKeys.add(emoji.key);
45
+ }
46
+ const buttonStyle = entry.buttonStyle ?? "secondary";
47
+ if (!["primary", "secondary", "success", "danger"].includes(buttonStyle))
48
+ throw new Error("Choose a valid button colour");
49
+ return {
50
+ entryId,
51
+ roleId,
52
+ nameplate,
53
+ ...(emoji ? { emoji: emoji.identifier } : {}),
54
+ ...(input.controlType === "buttons" ? { buttonStyle } : {}),
55
+ };
56
+ });
57
+ const embedColour = input.embedColour === undefined ? 0x7c3aed : input.embedColour;
58
+ if (!Number.isInteger(embedColour) ||
59
+ embedColour < 0 ||
60
+ embedColour > 0xffffff)
61
+ throw new Error("Embed colour must be between 0 and 16777215");
62
+ const dropdownPlaceholder = input.dropdownPlaceholder?.trim()
63
+ ? boundedText(input.dropdownPlaceholder, "Dropdown label", 100)
64
+ : "Choose your roles";
65
+ return {
66
+ channelId,
67
+ title,
68
+ messageBody,
69
+ messageStyle: input.messageStyle,
70
+ controlType: input.controlType,
71
+ selectionMode: input.selectionMode,
72
+ persistentReactions,
73
+ ...(input.messageStyle === "embed" ? { embedColour } : {}),
74
+ ...(input.controlType === "dropdown" ? { dropdownPlaceholder } : {}),
75
+ entries,
76
+ };
77
+ }
78
+ export function panelMessageDraft(input) {
79
+ const panel = validatePanelDraft(input);
80
+ const body = panel.controlType === "reactions"
81
+ ? `${panel.messageBody}\n\n${panel.entries
82
+ .map((entry) => `${displayEmoji(entry.emoji)} — <@&${entry.roleId}>`)
83
+ .join("\n")}`
84
+ : panel.messageBody;
85
+ const presentation = panel.messageStyle === "embed"
86
+ ? {
87
+ type: "embed",
88
+ title: panel.title,
89
+ body,
90
+ ...(panel.embedColour === undefined
91
+ ? {}
92
+ : { color: panel.embedColour }),
93
+ }
94
+ : {
95
+ type: "text",
96
+ title: panel.title,
97
+ body,
98
+ accentColor: 0x7c3aed,
99
+ };
100
+ const allowedUserMentionIds = [];
101
+ if (panel.controlType === "buttons") {
102
+ return {
103
+ presentation,
104
+ allowedUserMentionIds,
105
+ buttons: panel.entries.map((entry, index) => ({
106
+ customId: COMPONENT_IDS.slots[index],
107
+ label: entry.nameplate,
108
+ style: entry.buttonStyle ?? "secondary",
109
+ ...(entry.emoji
110
+ ? { emojiIdentifier: canonicaliseEmoji(entry.emoji).identifier }
111
+ : {}),
112
+ })),
113
+ };
114
+ }
115
+ if (panel.controlType === "dropdown") {
116
+ return {
117
+ presentation,
118
+ allowedUserMentionIds,
119
+ selects: [
120
+ {
121
+ customId: COMPONENT_IDS.select,
122
+ placeholder: panel.dropdownPlaceholder ?? "Choose your roles",
123
+ minValues: 0,
124
+ maxValues: panel.selectionMode === "one_only" ? 1 : panel.entries.length,
125
+ options: panel.entries.map((entry) => ({
126
+ label: entry.nameplate,
127
+ value: entry.entryId,
128
+ ...(entry.emoji
129
+ ? { emojiIdentifier: canonicaliseEmoji(entry.emoji).identifier }
130
+ : {}),
131
+ })),
132
+ },
133
+ ],
134
+ buttons: [
135
+ {
136
+ customId: COMPONENT_IDS.removeAll,
137
+ label: "Remove my panel roles",
138
+ style: "secondary",
139
+ },
140
+ ],
141
+ };
142
+ }
143
+ return {
144
+ presentation,
145
+ reactionIdentifiers: panel.entries.map((entry) => canonicaliseEmoji(entry.emoji).identifier),
146
+ allowedUserMentionIds,
147
+ };
148
+ }
149
+ function displayEmoji(input) {
150
+ const emoji = canonicaliseEmoji(input);
151
+ return emoji.id ? `<:${emoji.name}:${emoji.id}>` : emoji.name;
152
+ }
153
+ export function canonicaliseEmoji(input) {
154
+ const value = input.trim().normalize("NFKC");
155
+ const mention = /^<(a?):([A-Za-z0-9_]{2,32}):(\d{17,20})>$/u.exec(value);
156
+ const identifier = /^([A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(value);
157
+ if (mention || identifier) {
158
+ const animated = mention?.[1] === "a";
159
+ const name = mention?.[2] ?? identifier[1];
160
+ const id = mention?.[3] ?? identifier[2];
161
+ return {
162
+ kind: "custom",
163
+ name,
164
+ id,
165
+ animated,
166
+ identifier: `${name}:${id}`,
167
+ key: `custom:${id}`,
168
+ };
169
+ }
170
+ if (value.length === 0 ||
171
+ value.length > 32 ||
172
+ /^[\p{L}\p{N}\s.,!?_-]+$/u.test(value))
173
+ throw new Error("Enter one Unicode emoji or a custom server emoji");
174
+ return {
175
+ kind: "unicode",
176
+ name: value,
177
+ animated: false,
178
+ identifier: value,
179
+ key: `unicode:${value}`,
180
+ };
181
+ }
182
+ function boundedText(input, label, maximum) {
183
+ const value = input.trim().normalize("NFC");
184
+ if (value.length < 1 || value.length > maximum)
185
+ throw new Error(`${label} must be between 1 and ${maximum} characters`);
186
+ return value;
187
+ }
188
+ function snowflake(input, label) {
189
+ const value = input.trim();
190
+ if (!/^\d{17,20}$/u.test(value))
191
+ throw new Error(`${label} is not a valid Discord resource`);
192
+ return value;
193
+ }
194
+ function boundedIdentifier(input, label) {
195
+ const value = input.trim();
196
+ if (!/^[A-Za-z0-9_-]{1,100}$/u.test(value))
197
+ throw new Error(`${label} identifier is invalid`);
198
+ return value;
199
+ }
200
+ function controlLabel(type) {
201
+ return {
202
+ buttons: "Button",
203
+ dropdown: "Dropdown",
204
+ reactions: "Emoji-reaction",
205
+ }[type];
206
+ }
@@ -0,0 +1,22 @@
1
+ export declare const MODULE_ID = "helyx.reactionroles";
2
+ export declare const PANEL_PURPOSE = "helyx.reactionroles.builder";
3
+ export declare const COMPONENT_IDS: {
4
+ readonly addEntry: "helyx.reactionroles.builder.add-entry";
5
+ readonly continueDraft: "helyx.reactionroles.builder.continue";
6
+ readonly replaceDraft: "helyx.reactionroles.builder.replace";
7
+ readonly keepDraft: "helyx.reactionroles.builder.keep";
8
+ readonly publish: "helyx.reactionroles.builder.publish";
9
+ readonly cancel: "helyx.reactionroles.builder.cancel";
10
+ readonly deleteConfirm: "helyx.reactionroles.delete.confirm";
11
+ readonly basicModal: "helyx.reactionroles.builder.basic";
12
+ readonly entryModal: "helyx.reactionroles.builder.entry";
13
+ readonly builderEntries: "helyx.reactionroles.builder.entries";
14
+ readonly select: "helyx.reactionroles.select";
15
+ readonly removeAll: "helyx.reactionroles.remove-all";
16
+ readonly slots: string[];
17
+ };
18
+ export declare const PANEL_LIMITS: {
19
+ readonly buttons: 5;
20
+ readonly dropdown: 25;
21
+ readonly reactions: 20;
22
+ };
@@ -0,0 +1,22 @@
1
+ export const MODULE_ID = "helyx.reactionroles";
2
+ export const PANEL_PURPOSE = "helyx.reactionroles.builder";
3
+ export const COMPONENT_IDS = {
4
+ addEntry: "helyx.reactionroles.builder.add-entry",
5
+ continueDraft: "helyx.reactionroles.builder.continue",
6
+ replaceDraft: "helyx.reactionroles.builder.replace",
7
+ keepDraft: "helyx.reactionroles.builder.keep",
8
+ publish: "helyx.reactionroles.builder.publish",
9
+ cancel: "helyx.reactionroles.builder.cancel",
10
+ deleteConfirm: "helyx.reactionroles.delete.confirm",
11
+ basicModal: "helyx.reactionroles.builder.basic",
12
+ entryModal: "helyx.reactionroles.builder.entry",
13
+ builderEntries: "helyx.reactionroles.builder.entries",
14
+ select: "helyx.reactionroles.select",
15
+ removeAll: "helyx.reactionroles.remove-all",
16
+ slots: Array.from({ length: 5 }, (_, index) => `helyx.reactionroles.slot.${index + 1}`),
17
+ };
18
+ export const PANEL_LIMITS = {
19
+ buttons: 5,
20
+ dropdown: 25,
21
+ reactions: 20,
22
+ };
@@ -0,0 +1,2 @@
1
+ export declare const reactionRolesModule: import("@helyx/sdk").ModuleDefinition;
2
+ export default reactionRolesModule;