@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/CHANGELOG.md +18 -0
- package/LICENSE +725 -0
- package/README.md +53 -0
- package/dist/builder.d.ts +38 -0
- package/dist/builder.js +206 -0
- package/dist/constants.d.ts +22 -0
- package/dist/constants.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1618 -0
- package/dist/panel-service.d.ts +86 -0
- package/dist/panel-service.js +961 -0
- package/dist/records.d.ts +288 -0
- package/dist/records.js +623 -0
- package/manifest.json +310 -0
- package/migrations/0001_reactionroles_foundation.sql +108 -0
- package/migrations/0002_cleanup_retry_order.sql +6 -0
- package/migrations/0003_persistent_reactions.sql +37 -0
- package/package.json +55 -0
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;
|
package/dist/builder.js
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED