@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,33 @@
|
|
|
1
|
+
export declare const reactionRoleLogging: {
|
|
2
|
+
readonly events: readonly [{
|
|
3
|
+
readonly id: "reaction-role-panel-created";
|
|
4
|
+
readonly name: "Role panel created";
|
|
5
|
+
readonly description: "A server manager created a Reaction Roles panel.";
|
|
6
|
+
readonly defaultEnabled: true;
|
|
7
|
+
}, {
|
|
8
|
+
readonly id: "reaction-role-panel-deleted";
|
|
9
|
+
readonly name: "Role panel deleted";
|
|
10
|
+
readonly description: "A server manager deleted a Reaction Roles panel.";
|
|
11
|
+
readonly defaultEnabled: true;
|
|
12
|
+
}, {
|
|
13
|
+
readonly id: "reaction-role-panel-updated";
|
|
14
|
+
readonly name: "Role panel updated";
|
|
15
|
+
readonly description: "A server manager updated a Reaction Roles panel.";
|
|
16
|
+
readonly defaultEnabled: true;
|
|
17
|
+
}, {
|
|
18
|
+
readonly id: "reaction-role-panel-reposted";
|
|
19
|
+
readonly name: "Role panel reposted";
|
|
20
|
+
readonly description: "A server manager published a replacement Reaction Roles message.";
|
|
21
|
+
readonly defaultEnabled: true;
|
|
22
|
+
}, {
|
|
23
|
+
readonly id: "reaction-role-panel-repaired";
|
|
24
|
+
readonly name: "Role panel repaired";
|
|
25
|
+
readonly description: "A server manager repaired a Reaction Roles panel.";
|
|
26
|
+
readonly defaultEnabled: true;
|
|
27
|
+
}, {
|
|
28
|
+
readonly id: "reaction-role-member-changed";
|
|
29
|
+
readonly name: "Member role changed";
|
|
30
|
+
readonly description: "A member added or removed an approved role through a panel.";
|
|
31
|
+
readonly defaultEnabled: false;
|
|
32
|
+
}];
|
|
33
|
+
};
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const reactionRoleLogging = {
|
|
2
|
+
events: [
|
|
3
|
+
{
|
|
4
|
+
id: "reaction-role-panel-created",
|
|
5
|
+
name: "Role panel created",
|
|
6
|
+
description: "A server manager created a Reaction Roles panel.",
|
|
7
|
+
defaultEnabled: true,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "reaction-role-panel-deleted",
|
|
11
|
+
name: "Role panel deleted",
|
|
12
|
+
description: "A server manager deleted a Reaction Roles panel.",
|
|
13
|
+
defaultEnabled: true,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "reaction-role-panel-updated",
|
|
17
|
+
name: "Role panel updated",
|
|
18
|
+
description: "A server manager updated a Reaction Roles panel.",
|
|
19
|
+
defaultEnabled: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "reaction-role-panel-reposted",
|
|
23
|
+
name: "Role panel reposted",
|
|
24
|
+
description: "A server manager published a replacement Reaction Roles message.",
|
|
25
|
+
defaultEnabled: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "reaction-role-panel-repaired",
|
|
29
|
+
name: "Role panel repaired",
|
|
30
|
+
description: "A server manager repaired a Reaction Roles panel.",
|
|
31
|
+
defaultEnabled: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "reaction-role-member-changed",
|
|
35
|
+
name: "Member role changed",
|
|
36
|
+
description: "A member added or removed an approved role through a panel.",
|
|
37
|
+
defaultEnabled: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { DashboardActionValidationError } from "@helyx/sdk";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { managedContext, parseManagedDraft, toDetail, } from "./panel-service.js";
|
|
4
|
+
import { reactionRolePanels as panels, runReactionRolePrimary as runPrimary, } from "./runtime-state.js";
|
|
5
|
+
export const managedPanels = {
|
|
6
|
+
id: "panels",
|
|
7
|
+
list: (context, input) => runManaged(() => runPrimary(() => panels.list(context, input))),
|
|
8
|
+
read: (context, resourceId) => runManaged(() => runPrimary(() => panels.read(context, resourceId))),
|
|
9
|
+
preview: (context, value) => runManaged(() => runPrimary(() => panels.preview(context, value))),
|
|
10
|
+
async create(context, value) {
|
|
11
|
+
const panel = await runManaged(() => runPrimary(() => panels.create(managedContext(context), parseManagedDraft(value))));
|
|
12
|
+
return toDetail(panel);
|
|
13
|
+
},
|
|
14
|
+
async update(context, input) {
|
|
15
|
+
const panel = await runManaged(() => runPrimary(() => panels.update(managedContext(context), {
|
|
16
|
+
resourceId: input.resourceId,
|
|
17
|
+
expectedRevision: input.expectedRevision,
|
|
18
|
+
draft: parseManagedDraft(input.value),
|
|
19
|
+
})));
|
|
20
|
+
return toDetail(panel);
|
|
21
|
+
},
|
|
22
|
+
delete: (context, input) => runManaged(() => runPrimary(() => panels.delete(managedContext(context), input.resourceId, input.expectedRevision)).then((deleted) => ({ deleted }))),
|
|
23
|
+
executeAction: (context, input) => runManaged(async () => {
|
|
24
|
+
if (input.actionId === "duplicate") {
|
|
25
|
+
return runPrimary(async () => {
|
|
26
|
+
const current = await panels
|
|
27
|
+
.repository(context.services)
|
|
28
|
+
.findById(context.guildId, input.resourceId);
|
|
29
|
+
if (!current)
|
|
30
|
+
throw new DashboardActionValidationError("Role panel was not found");
|
|
31
|
+
if (current.revision !== input.expectedRevision)
|
|
32
|
+
throw new DashboardActionValidationError("This role panel changed in another session. Reload and try again.");
|
|
33
|
+
return toDetail(await panels.create(managedContext(context), {
|
|
34
|
+
channelId: current.channelId,
|
|
35
|
+
title: current.title,
|
|
36
|
+
messageBody: current.messageBody,
|
|
37
|
+
messageStyle: current.messageStyle,
|
|
38
|
+
controlType: current.controlType,
|
|
39
|
+
selectionMode: current.selectionMode,
|
|
40
|
+
persistentReactions: current.persistentReactions,
|
|
41
|
+
...(current.embedColour === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { embedColour: current.embedColour }),
|
|
44
|
+
...(current.dropdownPlaceholder === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { dropdownPlaceholder: current.dropdownPlaceholder }),
|
|
47
|
+
entries: current.entries.map((entry) => ({
|
|
48
|
+
...entry,
|
|
49
|
+
entryId: `entry-${randomUUID().replaceAll("-", "")}`,
|
|
50
|
+
})),
|
|
51
|
+
}));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (input.actionId === "delete") {
|
|
55
|
+
await runPrimary(() => panels.delete(managedContext(context), input.resourceId, input.expectedRevision));
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (input.actionId !== "repair" && input.actionId !== "repost")
|
|
59
|
+
throw new DashboardActionValidationError("This Reaction Roles action is not supported.");
|
|
60
|
+
return toDetail(await runPrimary(() => panels.repair(managedContext(context), input.resourceId, input.expectedRevision, input.actionId === "repost")));
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
async function runManaged(operation) {
|
|
64
|
+
try {
|
|
65
|
+
return await operation();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof DashboardActionValidationError)
|
|
69
|
+
throw error;
|
|
70
|
+
throw new DashboardActionValidationError("Reaction Roles is temporarily unavailable. Reload the current state before retrying.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ButtonContribution, MessageReactionAddContext, MessageReactionRemoveContext, MessageResponse, SelectInteractionContext, ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import type { StoredPanel } from "./records.js";
|
|
3
|
+
export declare function toggleSlot(context: Parameters<ButtonContribution["execute"]>[0], index: number): Promise<void>;
|
|
4
|
+
export declare function reconcileSelection(context: SelectInteractionContext): Promise<void>;
|
|
5
|
+
export declare function removeAllPanelRoles(context: Parameters<ButtonContribution["execute"]>[0]): Promise<void>;
|
|
6
|
+
export declare function handleReactionAdd(context: MessageReactionAddContext): Promise<void>;
|
|
7
|
+
export declare function handleReactionRemove(context: MessageReactionRemoveContext): Promise<void>;
|
|
8
|
+
export declare function activePanelForInteraction(context: {
|
|
9
|
+
serverId?: string;
|
|
10
|
+
messageId?: string;
|
|
11
|
+
services: ServiceAccess;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
guildId: string;
|
|
14
|
+
panel: StoredPanel;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function consumeMemberLimit(services: ServiceAccess, guildId: string, userId: string): Promise<void>;
|
|
17
|
+
export declare function withMemberLock(guildId: string, panelId: string, userId: string, operation: () => Promise<void>): Promise<void>;
|
|
18
|
+
export declare function memberOutcome(nameplate: string, changed: "added" | "removed"): MessageResponse;
|
|
19
|
+
export declare function roleFailure(context: {
|
|
20
|
+
services: ServiceAccess;
|
|
21
|
+
reply(response: MessageResponse): Promise<void>;
|
|
22
|
+
}, panel: StoredPanel): Promise<void>;
|
|
23
|
+
export declare function emitMemberChanges(services: ServiceAccess, guildId: string, memberUserId: string, panelId: string, changes: readonly {
|
|
24
|
+
roleId: string;
|
|
25
|
+
outcome: "added" | "removed";
|
|
26
|
+
}[]): Promise<void>;
|
|
27
|
+
export declare function canonicalEmojiKey(value: string): string;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
|
+
import { REACTION_ROLES_MODULE_ID } from "./constants.js";
|
|
3
|
+
import { reactionRoleMemberLocks as memberLocks, reactionRoleHealth as moduleHealth, reactionRolePanels as panels, } from "./runtime-state.js";
|
|
4
|
+
import { stalePanelReply } from "./builder-presentation.js";
|
|
5
|
+
export async function toggleSlot(context, index) {
|
|
6
|
+
const { guildId, panel } = await activePanelForInteraction(context);
|
|
7
|
+
const entry = panel.entries[index];
|
|
8
|
+
if (!entry)
|
|
9
|
+
return stalePanelReply(context);
|
|
10
|
+
await consumeMemberLimit(context.services, guildId, context.userId);
|
|
11
|
+
let changed = "removed";
|
|
12
|
+
try {
|
|
13
|
+
await withMemberLock(guildId, panel.panelId, context.userId, async () => {
|
|
14
|
+
const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
|
|
15
|
+
const current = new Set(await roles.getCurrentRoleIds({
|
|
16
|
+
guildId,
|
|
17
|
+
memberUserId: context.userId,
|
|
18
|
+
}));
|
|
19
|
+
const changes = [];
|
|
20
|
+
if (panel.selectionMode === "one_only") {
|
|
21
|
+
const desired = current.has(entry.roleId) ? [] : [entry.roleId];
|
|
22
|
+
const result = await roles.reconcileApprovedRoles({
|
|
23
|
+
guildId,
|
|
24
|
+
memberUserId: context.userId,
|
|
25
|
+
allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
|
|
26
|
+
desiredRoleIds: desired,
|
|
27
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
28
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
29
|
+
});
|
|
30
|
+
changes.push(...result.added.map((roleId) => ({
|
|
31
|
+
roleId,
|
|
32
|
+
outcome: "added",
|
|
33
|
+
})), ...result.removed.map((roleId) => ({
|
|
34
|
+
roleId,
|
|
35
|
+
outcome: "removed",
|
|
36
|
+
})));
|
|
37
|
+
changed = result.added.includes(entry.roleId) ? "added" : "removed";
|
|
38
|
+
}
|
|
39
|
+
else if (current.has(entry.roleId)) {
|
|
40
|
+
await roles.removeApprovedRole({
|
|
41
|
+
guildId,
|
|
42
|
+
memberUserId: context.userId,
|
|
43
|
+
roleId: entry.roleId,
|
|
44
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
45
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
46
|
+
});
|
|
47
|
+
changed = "removed";
|
|
48
|
+
changes.push({ roleId: entry.roleId, outcome: changed });
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await roles.addApprovedRole({
|
|
52
|
+
guildId,
|
|
53
|
+
memberUserId: context.userId,
|
|
54
|
+
roleId: entry.roleId,
|
|
55
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
56
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
57
|
+
});
|
|
58
|
+
changed = "added";
|
|
59
|
+
changes.push({ roleId: entry.roleId, outcome: changed });
|
|
60
|
+
}
|
|
61
|
+
await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, changes);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
moduleHealth.primaryFailure("member");
|
|
66
|
+
await roleFailure(context, panel);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
moduleHealth.primarySuccess("member");
|
|
70
|
+
await context.reply(memberOutcome(entry.nameplate, changed));
|
|
71
|
+
}
|
|
72
|
+
export async function reconcileSelection(context) {
|
|
73
|
+
const { guildId, panel } = await activePanelForInteraction(context);
|
|
74
|
+
if (panel.controlType !== "dropdown")
|
|
75
|
+
return stalePanelReply(context);
|
|
76
|
+
await consumeMemberLimit(context.services, guildId, context.userId);
|
|
77
|
+
const selected = new Set(context.selectedValues);
|
|
78
|
+
const desired = panel.entries
|
|
79
|
+
.filter(({ entryId }) => selected.has(entryId))
|
|
80
|
+
.map(({ roleId }) => roleId);
|
|
81
|
+
if (selected.size !== desired.length ||
|
|
82
|
+
(panel.selectionMode === "one_only" && desired.length > 1))
|
|
83
|
+
return stalePanelReply(context);
|
|
84
|
+
let added = 0;
|
|
85
|
+
let removed = 0;
|
|
86
|
+
try {
|
|
87
|
+
await withMemberLock(guildId, panel.panelId, context.userId, async () => {
|
|
88
|
+
const result = await context.services
|
|
89
|
+
.get(HELYX_SERVICE_NAMES.memberRoles)
|
|
90
|
+
.reconcileApprovedRoles({
|
|
91
|
+
guildId,
|
|
92
|
+
memberUserId: context.userId,
|
|
93
|
+
allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
|
|
94
|
+
desiredRoleIds: desired,
|
|
95
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
96
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
97
|
+
});
|
|
98
|
+
added = result.added.length;
|
|
99
|
+
removed = result.removed.length;
|
|
100
|
+
await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, [
|
|
101
|
+
...result.added.map((roleId) => ({
|
|
102
|
+
roleId,
|
|
103
|
+
outcome: "added",
|
|
104
|
+
})),
|
|
105
|
+
...result.removed.map((roleId) => ({
|
|
106
|
+
roleId,
|
|
107
|
+
outcome: "removed",
|
|
108
|
+
})),
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
moduleHealth.primaryFailure("member");
|
|
114
|
+
await roleFailure(context, panel);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
moduleHealth.primarySuccess("member");
|
|
118
|
+
await context.reply({
|
|
119
|
+
ephemeral: true,
|
|
120
|
+
componentsV2: {
|
|
121
|
+
text: [
|
|
122
|
+
{
|
|
123
|
+
content: `Your panel roles were updated: ${added} added, ${removed} removed.`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export async function removeAllPanelRoles(context) {
|
|
130
|
+
const { guildId, panel } = await activePanelForInteraction(context);
|
|
131
|
+
await consumeMemberLimit(context.services, guildId, context.userId);
|
|
132
|
+
let removed = [];
|
|
133
|
+
try {
|
|
134
|
+
await withMemberLock(guildId, panel.panelId, context.userId, async () => {
|
|
135
|
+
const result = await context.services
|
|
136
|
+
.get(HELYX_SERVICE_NAMES.memberRoles)
|
|
137
|
+
.reconcileApprovedRoles({
|
|
138
|
+
guildId,
|
|
139
|
+
memberUserId: context.userId,
|
|
140
|
+
allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
|
|
141
|
+
desiredRoleIds: [],
|
|
142
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
143
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
144
|
+
});
|
|
145
|
+
removed = result.removed;
|
|
146
|
+
await emitMemberChanges(context.services, guildId, context.userId, panel.panelId, result.removed.map((roleId) => ({
|
|
147
|
+
roleId,
|
|
148
|
+
outcome: "removed",
|
|
149
|
+
})));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
moduleHealth.primaryFailure("member");
|
|
154
|
+
await roleFailure(context, panel);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
moduleHealth.primarySuccess("member");
|
|
158
|
+
await context.reply({
|
|
159
|
+
ephemeral: true,
|
|
160
|
+
componentsV2: {
|
|
161
|
+
text: [
|
|
162
|
+
{
|
|
163
|
+
content: removed.length === 0
|
|
164
|
+
? "You did not have any roles from this panel."
|
|
165
|
+
: `${removed.length} panel ${removed.length === 1 ? "role was" : "roles were"} removed.`,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export async function handleReactionAdd(context) {
|
|
172
|
+
if (context.reactionType !== "normal")
|
|
173
|
+
return;
|
|
174
|
+
const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
|
|
175
|
+
if (!panel || panel.controlType !== "reactions")
|
|
176
|
+
return;
|
|
177
|
+
const entry = panel.entries.find((candidate) => candidate.emoji &&
|
|
178
|
+
canonicalEmojiKey(candidate.emoji) === context.emojiKey);
|
|
179
|
+
if (!entry)
|
|
180
|
+
return;
|
|
181
|
+
const changes = [];
|
|
182
|
+
try {
|
|
183
|
+
await withMemberLock(context.serverId, panel.panelId, context.userId, async () => {
|
|
184
|
+
const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
|
|
185
|
+
if (panel.selectionMode === "one_only") {
|
|
186
|
+
const result = await roles.reconcileApprovedRoles({
|
|
187
|
+
guildId: context.serverId,
|
|
188
|
+
memberUserId: context.userId,
|
|
189
|
+
allowedRoleIds: panel.entries.map(({ roleId }) => roleId),
|
|
190
|
+
desiredRoleIds: [entry.roleId],
|
|
191
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
192
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
193
|
+
});
|
|
194
|
+
changes.push(...result.added.map((roleId) => ({
|
|
195
|
+
roleId,
|
|
196
|
+
outcome: "added",
|
|
197
|
+
})), ...result.removed.map((roleId) => ({
|
|
198
|
+
roleId,
|
|
199
|
+
outcome: "removed",
|
|
200
|
+
})));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const result = await roles.addApprovedRole({
|
|
204
|
+
guildId: context.serverId,
|
|
205
|
+
memberUserId: context.userId,
|
|
206
|
+
roleId: entry.roleId,
|
|
207
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
208
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
209
|
+
});
|
|
210
|
+
if (result.changed)
|
|
211
|
+
changes.push({ roleId: entry.roleId, outcome: "added" });
|
|
212
|
+
});
|
|
213
|
+
await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, changes);
|
|
214
|
+
moduleHealth.primarySuccess("member");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
moduleHealth.primaryFailure("member");
|
|
218
|
+
await panels.markNeedsAttention(panel, context.services).catch(() => false);
|
|
219
|
+
await context.removeAddedReaction().catch(() => undefined);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!panel.persistentReactions) {
|
|
223
|
+
try {
|
|
224
|
+
await context.removeAddedReaction();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
await panels
|
|
228
|
+
.markNeedsAttention(panel, context.services)
|
|
229
|
+
.catch(() => false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export async function handleReactionRemove(context) {
|
|
234
|
+
if (context.reactionType !== "normal")
|
|
235
|
+
return;
|
|
236
|
+
const panel = await panels.resolveActive(context.serverId, context.messageId, context.services);
|
|
237
|
+
if (!panel || panel.controlType !== "reactions" || !panel.persistentReactions)
|
|
238
|
+
return;
|
|
239
|
+
const entry = panel.entries.find((candidate) => candidate.emoji &&
|
|
240
|
+
canonicalEmojiKey(candidate.emoji) === context.emojiKey);
|
|
241
|
+
if (!entry)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
await withMemberLock(context.serverId, panel.panelId, context.userId, () => context.services
|
|
245
|
+
.get(HELYX_SERVICE_NAMES.memberRoles)
|
|
246
|
+
.removeApprovedRole({
|
|
247
|
+
guildId: context.serverId,
|
|
248
|
+
memberUserId: context.userId,
|
|
249
|
+
roleId: entry.roleId,
|
|
250
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
251
|
+
reason: `Reaction Roles panel ${panel.panelId}`,
|
|
252
|
+
})
|
|
253
|
+
.then(() => undefined));
|
|
254
|
+
await emitMemberChanges(context.services, context.serverId, context.userId, panel.panelId, [{ roleId: entry.roleId, outcome: "removed" }]);
|
|
255
|
+
moduleHealth.primarySuccess("member");
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
moduleHealth.primaryFailure("member");
|
|
259
|
+
await panels.markNeedsAttention(panel, context.services).catch(() => false);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function activePanelForInteraction(context) {
|
|
263
|
+
const guildId = context.serverId;
|
|
264
|
+
if (!guildId || !context.messageId)
|
|
265
|
+
throw new Error("Server panel required");
|
|
266
|
+
let panel;
|
|
267
|
+
try {
|
|
268
|
+
panel = await panels.resolveActive(guildId, context.messageId, context.services);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
moduleHealth.primaryFailure("member");
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
if (!panel)
|
|
275
|
+
throw new Error("Role panel is no longer active");
|
|
276
|
+
return { guildId, panel };
|
|
277
|
+
}
|
|
278
|
+
export async function consumeMemberLimit(services, guildId, userId) {
|
|
279
|
+
let result;
|
|
280
|
+
try {
|
|
281
|
+
result = await services
|
|
282
|
+
.get(HELYX_SERVICE_NAMES.rateLimits)
|
|
283
|
+
.consume({
|
|
284
|
+
scope: "user",
|
|
285
|
+
subjectId: userId,
|
|
286
|
+
action: `${REACTION_ROLES_MODULE_ID}:${guildId}:member-role`,
|
|
287
|
+
limit: 10,
|
|
288
|
+
windowSeconds: 10,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
moduleHealth.primaryFailure("member");
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
if (!result.allowed)
|
|
296
|
+
throw new Error("Role panel rate limit reached");
|
|
297
|
+
}
|
|
298
|
+
export async function withMemberLock(guildId, panelId, userId, operation) {
|
|
299
|
+
const key = `${guildId}:${panelId}:${userId}`;
|
|
300
|
+
const previous = memberLocks.get(key) ?? Promise.resolve();
|
|
301
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
302
|
+
memberLocks.set(key, current);
|
|
303
|
+
try {
|
|
304
|
+
await current;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (memberLocks.get(key) === current)
|
|
308
|
+
memberLocks.delete(key);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export function memberOutcome(nameplate, changed) {
|
|
312
|
+
return {
|
|
313
|
+
ephemeral: true,
|
|
314
|
+
componentsV2: {
|
|
315
|
+
text: [
|
|
316
|
+
{
|
|
317
|
+
content: `${nameplate} was ${changed === "added" ? "added to" : "removed from"} your roles.`,
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
allowedUserMentionIds: [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
export async function roleFailure(context, panel) {
|
|
325
|
+
await panels.markNeedsAttention(panel, context.services).catch(() => false);
|
|
326
|
+
await context.reply({
|
|
327
|
+
ephemeral: true,
|
|
328
|
+
componentsV2: {
|
|
329
|
+
text: [
|
|
330
|
+
{
|
|
331
|
+
content: "Helyx cannot manage that role right now. A server administrator needs to check the Reaction Roles panel.",
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
allowedUserMentionIds: [],
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
export async function emitMemberChanges(services, guildId, memberUserId, panelId, changes) {
|
|
339
|
+
if (changes.length === 0 || !services.has(HELYX_SERVICE_NAMES.moduleLogging))
|
|
340
|
+
return;
|
|
341
|
+
const logging = services.get(HELYX_SERVICE_NAMES.moduleLogging);
|
|
342
|
+
await Promise.all(changes.slice(0, 25).map(({ roleId, outcome }) => logging
|
|
343
|
+
.emit({
|
|
344
|
+
guildId,
|
|
345
|
+
moduleId: REACTION_ROLES_MODULE_ID,
|
|
346
|
+
eventId: "reaction-role-member-changed",
|
|
347
|
+
summary: "A member changed an approved Reaction Roles role.",
|
|
348
|
+
details: [
|
|
349
|
+
{ label: "Member ID", value: memberUserId },
|
|
350
|
+
{ label: "Panel", value: panelId },
|
|
351
|
+
{ label: "Role ID", value: roleId },
|
|
352
|
+
{ label: "Outcome", value: outcome },
|
|
353
|
+
],
|
|
354
|
+
})
|
|
355
|
+
.catch(() => ({ delivered: false }))));
|
|
356
|
+
}
|
|
357
|
+
export function canonicalEmojiKey(value) {
|
|
358
|
+
const custom = /^([A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(value);
|
|
359
|
+
return custom ? `custom:${custom[2]}` : `unicode:${value.normalize("NFKC")}`;
|
|
360
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { reactionRoleCache as cache, reactionRoleHealth as health, reactionRoleMemberLocks as memberLocks, reactionRolePanels as panels, } from "./runtime-state.js";
|
|
2
|
+
export async function startReactionRoles(context) {
|
|
3
|
+
health.start(context);
|
|
4
|
+
let cleanup = { completed: 0, failed: 0, abandoned: 0 };
|
|
5
|
+
let cleanupRecoveryFailed = false;
|
|
6
|
+
try {
|
|
7
|
+
cleanup = await panels.recoverMessageCleanup(context.services);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
cleanupRecoveryFailed = true;
|
|
11
|
+
context.logger.warn({
|
|
12
|
+
event: "reactionroles.cleanup_recovery_failed",
|
|
13
|
+
reason: "owned_message_cleanup_unavailable",
|
|
14
|
+
}, "Reaction Roles could not reconcile old owned messages");
|
|
15
|
+
}
|
|
16
|
+
let count = 0;
|
|
17
|
+
let cacheWarmFailed = false;
|
|
18
|
+
try {
|
|
19
|
+
count = await panels.warm(context.services);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
cacheWarmFailed = true;
|
|
23
|
+
context.logger.warn({
|
|
24
|
+
event: "reactionroles.cache_load_failed",
|
|
25
|
+
reason: "active_panel_preload_unavailable",
|
|
26
|
+
}, "Reaction Roles started without its active cache");
|
|
27
|
+
}
|
|
28
|
+
health.startupResult({
|
|
29
|
+
cacheWarmFailed,
|
|
30
|
+
cacheSaturated: cache.saturated(),
|
|
31
|
+
cleanupFailures: cleanup.failed + cleanup.abandoned + (cleanupRecoveryFailed ? 1 : 0),
|
|
32
|
+
});
|
|
33
|
+
context.logger.info({
|
|
34
|
+
event: "reactionroles.started",
|
|
35
|
+
activePanelCount: count,
|
|
36
|
+
cleanupCompleted: cleanup.completed,
|
|
37
|
+
cleanupFailed: cleanup.failed,
|
|
38
|
+
cleanupAbandoned: cleanup.abandoned,
|
|
39
|
+
cleanupRecoveryFailed,
|
|
40
|
+
}, "Reaction Roles started");
|
|
41
|
+
}
|
|
42
|
+
export function stopReactionRoles(context) {
|
|
43
|
+
cache.clear();
|
|
44
|
+
memberLocks.clear();
|
|
45
|
+
health.stop();
|
|
46
|
+
context.logger.info({ event: "reactionroles.stopped" }, "Reaction Roles stopped");
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { StoredPanel } from "./records.js";
|
|
2
|
+
export declare const PANEL_CACHE_LIMITS: {
|
|
3
|
+
readonly active: 10000;
|
|
4
|
+
readonly negative: 1000;
|
|
5
|
+
readonly loads: 1000;
|
|
6
|
+
readonly activeTtlMs: 60000;
|
|
7
|
+
readonly negativeTtlMs: 15000;
|
|
8
|
+
};
|
|
9
|
+
export interface PanelCacheStats {
|
|
10
|
+
active: number;
|
|
11
|
+
negative: number;
|
|
12
|
+
loads: number;
|
|
13
|
+
suspended: number;
|
|
14
|
+
saturated: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare class PanelSnapshotCache {
|
|
17
|
+
#private;
|
|
18
|
+
get(guildId: string, messageId: string): StoredPanel | null;
|
|
19
|
+
getFresh(guildId: string, messageId: string): StoredPanel | null;
|
|
20
|
+
negativeHit(guildId: string, messageId: string): boolean;
|
|
21
|
+
markMissing(guildId: string, messageId: string): void;
|
|
22
|
+
load(guildId: string, messageId: string, operation: () => Promise<StoredPanel | null>): Promise<StoredPanel | null>;
|
|
23
|
+
put(panel: StoredPanel): void;
|
|
24
|
+
remove(panel: Pick<StoredPanel, "guildId" | "messageId">): void;
|
|
25
|
+
suspend(panel: Pick<StoredPanel, "guildId" | "messageId">): (replacement?: StoredPanel | null) => void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
size(): number;
|
|
28
|
+
saturated(): boolean;
|
|
29
|
+
stats(): PanelCacheStats;
|
|
30
|
+
}
|