@helyx/bot 0.1.0
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/LICENSE +725 -0
- package/README.md +19 -0
- package/dist/control-server.d.ts +198 -0
- package/dist/control-server.js +663 -0
- package/dist/events.d.ts +7 -0
- package/dist/events.js +142 -0
- package/dist/guilds.d.ts +6 -0
- package/dist/guilds.js +53 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +185 -0
- package/dist/interactions.d.ts +26 -0
- package/dist/interactions.js +666 -0
- package/dist/management-module.d.ts +15 -0
- package/dist/management-module.js +865 -0
- package/dist/module-configuration.d.ts +10 -0
- package/dist/module-configuration.js +58 -0
- package/dist/module-logging.d.ts +21 -0
- package/dist/module-logging.js +97 -0
- package/dist/modules.d.ts +16 -0
- package/dist/modules.js +81 -0
- package/dist/permissions-module.d.ts +3 -0
- package/dist/permissions-module.js +497 -0
- package/package.json +62 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
|
|
2
|
+
const MODULE_SELECT_ID = "helyx.management.module";
|
|
3
|
+
const SETTING_SELECT_ID = "helyx.management.setting";
|
|
4
|
+
const VALUE_SELECT_ID = "helyx.management.value";
|
|
5
|
+
const ENABLE_ID = "helyx.management.enable";
|
|
6
|
+
const DISABLE_ID = "helyx.management.disable";
|
|
7
|
+
const CONFIGURE_ID = "helyx.management.configure";
|
|
8
|
+
const SAVE_ID = "helyx.management.save";
|
|
9
|
+
const CLEAR_ID = "helyx.management.clear";
|
|
10
|
+
const BACK_TO_MODULES_ID = "helyx.management.back.modules";
|
|
11
|
+
const BACK_TO_MODULE_ID = "helyx.management.back.module";
|
|
12
|
+
const CANCEL_ID = "helyx.management.cancel";
|
|
13
|
+
const MODULE_PREVIOUS_ID = "helyx.management.module.previous";
|
|
14
|
+
const MODULE_NEXT_ID = "helyx.management.module.next";
|
|
15
|
+
const SETTING_PREVIOUS_ID = "helyx.management.setting.previous";
|
|
16
|
+
const SETTING_NEXT_ID = "helyx.management.setting.next";
|
|
17
|
+
const VALUE_MODAL_ID = "helyx.management.value.modal";
|
|
18
|
+
const VALUE_FIELD_ID = "value";
|
|
19
|
+
const SESSION_PURPOSE = "modules.manage";
|
|
20
|
+
const PAGE_SIZE = 25;
|
|
21
|
+
const SESSION_LIFETIME_MS = 15 * 60 * 1_000;
|
|
22
|
+
const VIOLET = 0x7c3aed;
|
|
23
|
+
const SUCCESS = 0x16a34a;
|
|
24
|
+
const DANGER = 0xdc2626;
|
|
25
|
+
export function createManagementModule(configurableModules, resources) {
|
|
26
|
+
const modules = [...configurableModules].sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
|
|
27
|
+
const byId = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
28
|
+
return defineModule({
|
|
29
|
+
manifest: {
|
|
30
|
+
manifestVersion: "0.1",
|
|
31
|
+
id: "helyx.management",
|
|
32
|
+
name: "Helyx management",
|
|
33
|
+
version: "0.1.0",
|
|
34
|
+
description: "Manages installed Helyx modules from Discord.",
|
|
35
|
+
entry: "./dist/management-module.js",
|
|
36
|
+
framework: "^0.1.0",
|
|
37
|
+
dependencies: [],
|
|
38
|
+
permissions: [],
|
|
39
|
+
contributions: { commands: ["helyx"], events: [] },
|
|
40
|
+
},
|
|
41
|
+
defaultPermissions: {
|
|
42
|
+
module: restrictedPolicy(),
|
|
43
|
+
commands: { helyx: restrictedPolicy() },
|
|
44
|
+
},
|
|
45
|
+
interactions: {
|
|
46
|
+
commands: [
|
|
47
|
+
{
|
|
48
|
+
name: "helyx",
|
|
49
|
+
description: "Manage Helyx modules and settings",
|
|
50
|
+
registration: "global",
|
|
51
|
+
contexts: ["guild"],
|
|
52
|
+
integrationTypes: ["guild_install"],
|
|
53
|
+
rateLimit: { scope: "user", limit: 30, windowSeconds: 60 },
|
|
54
|
+
async execute(context) {
|
|
55
|
+
if (!context.serverId)
|
|
56
|
+
return;
|
|
57
|
+
if (modules.length === 0) {
|
|
58
|
+
await context.reply({
|
|
59
|
+
ephemeral: true,
|
|
60
|
+
componentsV2: {
|
|
61
|
+
accentColor: DANGER,
|
|
62
|
+
text: [
|
|
63
|
+
{
|
|
64
|
+
content: "## No feature modules installed\nInstall at least one official Helyx module before configuring this server.",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await context.services
|
|
72
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
73
|
+
.create({
|
|
74
|
+
guildId: context.serverId,
|
|
75
|
+
userId: context.userId,
|
|
76
|
+
purpose: SESSION_PURPOSE,
|
|
77
|
+
state: { modulePage: 0 },
|
|
78
|
+
expiresAt: sessionExpiry(),
|
|
79
|
+
replace: true,
|
|
80
|
+
});
|
|
81
|
+
await context.reply(await modulePickerResponse(context, modules, 0));
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
selects: [
|
|
86
|
+
{
|
|
87
|
+
customId: MODULE_SELECT_ID,
|
|
88
|
+
async execute(context) {
|
|
89
|
+
const session = await getSession(context);
|
|
90
|
+
if (!session || !context.serverId)
|
|
91
|
+
return;
|
|
92
|
+
const moduleId = context.selectedValues[0];
|
|
93
|
+
const module = moduleId ? byId.get(moduleId) : undefined;
|
|
94
|
+
if (!module)
|
|
95
|
+
throw new Error("Selected module is unavailable");
|
|
96
|
+
const configuration = await context.services
|
|
97
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
98
|
+
.get(context.serverId, module.manifest.id);
|
|
99
|
+
const state = {
|
|
100
|
+
...session.state,
|
|
101
|
+
moduleId,
|
|
102
|
+
settingPage: 0,
|
|
103
|
+
draft: initialDraft(module, configuration?.value),
|
|
104
|
+
expectedConfigurationVersion: configuration?.version.toString() ?? null,
|
|
105
|
+
};
|
|
106
|
+
await updateSession(context, session, state);
|
|
107
|
+
await respond(context, await moduleResponse(context, module));
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
customId: SETTING_SELECT_ID,
|
|
112
|
+
async execute(context) {
|
|
113
|
+
const session = await getSession(context);
|
|
114
|
+
if (!session)
|
|
115
|
+
return;
|
|
116
|
+
const module = selectedModule(session.state, byId);
|
|
117
|
+
const setting = selectedSetting(module, context.selectedValues[0]);
|
|
118
|
+
const state = { ...session.state, selectedSettingKey: setting.key };
|
|
119
|
+
await updateSession(context, session, state);
|
|
120
|
+
if (setting.type === "string" || setting.type === "integer") {
|
|
121
|
+
await context.showModal(settingModal(setting, draft(state)));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await respond(context, settingValueResponse(setting));
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
customId: VALUE_SELECT_ID,
|
|
129
|
+
async execute(context) {
|
|
130
|
+
const session = await getSession(context);
|
|
131
|
+
if (!session)
|
|
132
|
+
return;
|
|
133
|
+
const module = selectedModule(session.state, byId);
|
|
134
|
+
const setting = selectedSetting(module, stringState(session.state, "selectedSettingKey"));
|
|
135
|
+
const value = selectedValue(setting, context.selectedValues);
|
|
136
|
+
const nextDraft = { ...draft(session.state), [setting.key]: value };
|
|
137
|
+
const state = { ...session.state, draft: nextDraft };
|
|
138
|
+
const updated = await updateSession(context, session, state);
|
|
139
|
+
await respond(context, configurationResponse(module, updated.state));
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
buttons: [
|
|
144
|
+
statusButton(ENABLE_ID, true),
|
|
145
|
+
statusButton(DISABLE_ID, false),
|
|
146
|
+
{
|
|
147
|
+
customId: CONFIGURE_ID,
|
|
148
|
+
async execute(context) {
|
|
149
|
+
const session = await getSession(context);
|
|
150
|
+
if (!session)
|
|
151
|
+
return;
|
|
152
|
+
const module = selectedModule(session.state, byId);
|
|
153
|
+
if (!module.configuration ||
|
|
154
|
+
!module.manifest.dashboard?.settings.length) {
|
|
155
|
+
throw new Error("Selected module has no configurable settings");
|
|
156
|
+
}
|
|
157
|
+
await respond(context, configurationResponse(module, session.state));
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
customId: SAVE_ID,
|
|
162
|
+
async execute(context) {
|
|
163
|
+
const session = await getSession(context);
|
|
164
|
+
if (!session || !context.serverId)
|
|
165
|
+
return;
|
|
166
|
+
const module = selectedModule(session.state, byId);
|
|
167
|
+
const value = draft(session.state);
|
|
168
|
+
const problem = validateDraft(module, value);
|
|
169
|
+
if (problem) {
|
|
170
|
+
await context.reply(validationResponse(problem));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const resourceProblem = await validateDiscordSettings(resources, context.serverId, module, value);
|
|
174
|
+
if (resourceProblem) {
|
|
175
|
+
await context.reply(validationResponse(resourceProblem));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
module.configuration?.validate(value);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
await context.reply(validationResponse(error instanceof Error
|
|
183
|
+
? error.message
|
|
184
|
+
: "The module rejected these settings."));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const saved = await context.services
|
|
188
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
189
|
+
.update({
|
|
190
|
+
guildId: context.serverId,
|
|
191
|
+
moduleId: module.manifest.id,
|
|
192
|
+
value,
|
|
193
|
+
expectedVersion: optionalBigIntState(session.state, "expectedConfigurationVersion"),
|
|
194
|
+
context: {
|
|
195
|
+
actorUserId: context.userId,
|
|
196
|
+
correlationId: context.interactionId,
|
|
197
|
+
source: "discord",
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
await updateSession(context, session, {
|
|
201
|
+
...session.state,
|
|
202
|
+
draft: saved.value,
|
|
203
|
+
expectedConfigurationVersion: saved.version.toString(),
|
|
204
|
+
});
|
|
205
|
+
await respond(context, {
|
|
206
|
+
ephemeral: true,
|
|
207
|
+
componentsV2: {
|
|
208
|
+
accentColor: SUCCESS,
|
|
209
|
+
text: [
|
|
210
|
+
{
|
|
211
|
+
content: `## ${module.manifest.name} settings saved\nThe complete configuration was validated and saved for this server.`,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
buttons: [
|
|
216
|
+
button(BACK_TO_MODULE_ID, "Back to module", "secondary"),
|
|
217
|
+
button(CANCEL_ID, "Done", "success"),
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
customId: CLEAR_ID,
|
|
224
|
+
async execute(context) {
|
|
225
|
+
const session = await getSession(context);
|
|
226
|
+
if (!session)
|
|
227
|
+
return;
|
|
228
|
+
const module = selectedModule(session.state, byId);
|
|
229
|
+
const setting = selectedSetting(module, stringState(session.state, "selectedSettingKey"));
|
|
230
|
+
if (setting.required)
|
|
231
|
+
throw new Error("Required settings cannot be cleared");
|
|
232
|
+
const nextDraft = {
|
|
233
|
+
...draft(session.state),
|
|
234
|
+
[setting.key]: emptyValue(setting),
|
|
235
|
+
};
|
|
236
|
+
const updated = await updateSession(context, session, {
|
|
237
|
+
...session.state,
|
|
238
|
+
draft: nextDraft,
|
|
239
|
+
});
|
|
240
|
+
await respond(context, configurationResponse(module, updated.state));
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
customId: BACK_TO_MODULES_ID,
|
|
245
|
+
async execute(context) {
|
|
246
|
+
const session = await getSession(context);
|
|
247
|
+
if (!session)
|
|
248
|
+
return;
|
|
249
|
+
const page = numberState(session.state, "modulePage");
|
|
250
|
+
await respond(context, await modulePickerResponse(context, modules, page));
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
customId: BACK_TO_MODULE_ID,
|
|
255
|
+
async execute(context) {
|
|
256
|
+
const session = await getSession(context);
|
|
257
|
+
if (!session)
|
|
258
|
+
return;
|
|
259
|
+
await respond(context, await moduleResponse(context, selectedModule(session.state, byId)));
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
customId: CANCEL_ID,
|
|
264
|
+
async execute(context) {
|
|
265
|
+
const session = await getSession(context);
|
|
266
|
+
if (!session)
|
|
267
|
+
return;
|
|
268
|
+
await context.services
|
|
269
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
270
|
+
.delete(session.sessionId);
|
|
271
|
+
await respond(context, {
|
|
272
|
+
ephemeral: true,
|
|
273
|
+
componentsV2: {
|
|
274
|
+
accentColor: VIOLET,
|
|
275
|
+
text: [
|
|
276
|
+
{
|
|
277
|
+
content: "## Helyx management closed\nRun `/helyx` whenever you need to manage modules.",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
modulePageButton(MODULE_PREVIOUS_ID, -1),
|
|
285
|
+
modulePageButton(MODULE_NEXT_ID, 1),
|
|
286
|
+
settingPageButton(SETTING_PREVIOUS_ID, -1),
|
|
287
|
+
settingPageButton(SETTING_NEXT_ID, 1),
|
|
288
|
+
],
|
|
289
|
+
modals: [
|
|
290
|
+
{
|
|
291
|
+
customId: VALUE_MODAL_ID,
|
|
292
|
+
async execute(context) {
|
|
293
|
+
const session = await getSession(context);
|
|
294
|
+
if (!session)
|
|
295
|
+
return;
|
|
296
|
+
const module = selectedModule(session.state, byId);
|
|
297
|
+
const setting = selectedSetting(module, stringState(session.state, "selectedSettingKey"));
|
|
298
|
+
if (setting.type !== "string" && setting.type !== "integer") {
|
|
299
|
+
throw new Error("Selected setting does not use text input");
|
|
300
|
+
}
|
|
301
|
+
const parsed = parseModalValue(setting, context.getField(VALUE_FIELD_ID));
|
|
302
|
+
if (parsed.problem) {
|
|
303
|
+
await context.reply(validationResponse(parsed.problem));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const updated = await updateSession(context, session, {
|
|
307
|
+
...session.state,
|
|
308
|
+
draft: { ...draft(session.state), [setting.key]: parsed.value },
|
|
309
|
+
});
|
|
310
|
+
await respond(context, configurationResponse(module, updated.state));
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
start: () => Promise.resolve(),
|
|
316
|
+
stop: () => Promise.resolve(),
|
|
317
|
+
});
|
|
318
|
+
function statusButton(customId, enabled) {
|
|
319
|
+
return {
|
|
320
|
+
customId,
|
|
321
|
+
async execute(context) {
|
|
322
|
+
const session = await getSession(context);
|
|
323
|
+
if (!session || !context.serverId)
|
|
324
|
+
return;
|
|
325
|
+
const module = selectedModule(session.state, byId);
|
|
326
|
+
try {
|
|
327
|
+
await context.services
|
|
328
|
+
.get(HELYX_SERVICE_NAMES.installations)
|
|
329
|
+
.setModuleEnabled({
|
|
330
|
+
guildId: context.serverId,
|
|
331
|
+
moduleId: module.manifest.id,
|
|
332
|
+
enabled,
|
|
333
|
+
context: {
|
|
334
|
+
actorUserId: context.userId,
|
|
335
|
+
correlationId: context.interactionId,
|
|
336
|
+
source: "discord",
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (error instanceof ModuleStateValidationError) {
|
|
342
|
+
await context.reply(validationResponse(error.message));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
await respond(context, {
|
|
348
|
+
ephemeral: true,
|
|
349
|
+
componentsV2: {
|
|
350
|
+
accentColor: enabled ? SUCCESS : VIOLET,
|
|
351
|
+
text: [
|
|
352
|
+
{
|
|
353
|
+
content: `## ${module.manifest.name} ${enabled ? "enabled" : "disabled"}\n${enabled ? "Its configured commands and events can now run in this server." : "Its configuration and stored data have been retained."}`,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
buttons: [
|
|
358
|
+
button(BACK_TO_MODULE_ID, "Back to module", "secondary"),
|
|
359
|
+
button(CANCEL_ID, "Done", "success"),
|
|
360
|
+
],
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function modulePageButton(customId, change) {
|
|
366
|
+
return {
|
|
367
|
+
customId,
|
|
368
|
+
async execute(context) {
|
|
369
|
+
const session = await getSession(context);
|
|
370
|
+
if (!session)
|
|
371
|
+
return;
|
|
372
|
+
const page = clampPage(numberState(session.state, "modulePage") + change, pageCount(modules.length));
|
|
373
|
+
const updated = await updateSession(context, session, {
|
|
374
|
+
...session.state,
|
|
375
|
+
modulePage: page,
|
|
376
|
+
});
|
|
377
|
+
await respond(context, await modulePickerResponse(context, modules, page));
|
|
378
|
+
void updated;
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function settingPageButton(customId, change) {
|
|
383
|
+
return {
|
|
384
|
+
customId,
|
|
385
|
+
async execute(context) {
|
|
386
|
+
const session = await getSession(context);
|
|
387
|
+
if (!session)
|
|
388
|
+
return;
|
|
389
|
+
const module = selectedModule(session.state, byId);
|
|
390
|
+
const settings = module.manifest.dashboard?.settings ?? [];
|
|
391
|
+
const page = clampPage(numberState(session.state, "settingPage") + change, pageCount(settings.length));
|
|
392
|
+
const updated = await updateSession(context, session, {
|
|
393
|
+
...session.state,
|
|
394
|
+
settingPage: page,
|
|
395
|
+
});
|
|
396
|
+
await respond(context, configurationResponse(module, updated.state));
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function restrictedPolicy() {
|
|
402
|
+
return {
|
|
403
|
+
everyone: false,
|
|
404
|
+
requiredPermissions: ["MANAGE_GUILD"],
|
|
405
|
+
allowedRoleIds: [],
|
|
406
|
+
deniedRoleIds: [],
|
|
407
|
+
allowedUserIds: [],
|
|
408
|
+
deniedUserIds: [],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async function modulePickerResponse(context, modules, requestedPage) {
|
|
412
|
+
if (!context.serverId)
|
|
413
|
+
throw new Error("Server context is required");
|
|
414
|
+
const pages = pageCount(modules.length);
|
|
415
|
+
const page = clampPage(requestedPage, pages);
|
|
416
|
+
const visible = pageItems(modules, page);
|
|
417
|
+
const installations = context.services.get(HELYX_SERVICE_NAMES.installations);
|
|
418
|
+
const statuses = await Promise.all(visible.map((module) => installations.isModuleEnabled(context.serverId, module.manifest.id)));
|
|
419
|
+
return {
|
|
420
|
+
ephemeral: true,
|
|
421
|
+
componentsV2: {
|
|
422
|
+
accentColor: VIOLET,
|
|
423
|
+
text: [
|
|
424
|
+
{
|
|
425
|
+
content: "## Helyx modules\nChoose an installed module to manage its server-level state and settings." +
|
|
426
|
+
(pages > 1 ? ` Page ${String(page + 1)} of ${String(pages)}.` : ""),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
selects: [
|
|
431
|
+
{
|
|
432
|
+
customId: MODULE_SELECT_ID,
|
|
433
|
+
placeholder: "Choose an installed module",
|
|
434
|
+
options: visible.map((module, index) => ({
|
|
435
|
+
label: module.manifest.name,
|
|
436
|
+
value: module.manifest.id,
|
|
437
|
+
description: `${statuses[index] ? "Enabled" : "Disabled"} · ${truncate(module.manifest.description, 87)}`,
|
|
438
|
+
})),
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
buttons: [
|
|
442
|
+
...paginationButtons(page, pages, MODULE_PREVIOUS_ID, MODULE_NEXT_ID),
|
|
443
|
+
button(CANCEL_ID, "Close", "secondary"),
|
|
444
|
+
],
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async function moduleResponse(context, module) {
|
|
448
|
+
if (!context.serverId)
|
|
449
|
+
throw new Error("Server context is required");
|
|
450
|
+
const enabled = await context.services
|
|
451
|
+
.get(HELYX_SERVICE_NAMES.installations)
|
|
452
|
+
.isModuleEnabled(context.serverId, module.manifest.id);
|
|
453
|
+
const configurable = !!module.configuration && !!module.manifest.dashboard?.settings.length;
|
|
454
|
+
return {
|
|
455
|
+
ephemeral: true,
|
|
456
|
+
componentsV2: {
|
|
457
|
+
accentColor: enabled ? SUCCESS : VIOLET,
|
|
458
|
+
text: [
|
|
459
|
+
{
|
|
460
|
+
content: `## ${module.manifest.name}\n${module.manifest.description}\n\n**Status:** ${enabled ? "Enabled" : "Disabled"}\n**Installed version:** ${module.manifest.version}\n**Settings:** ${configurable ? "Available" : "No configurable settings"}`,
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
buttons: [
|
|
465
|
+
enabled
|
|
466
|
+
? button(DISABLE_ID, "Disable module", "danger")
|
|
467
|
+
: button(ENABLE_ID, "Enable module", "success"),
|
|
468
|
+
...(configurable
|
|
469
|
+
? [button(CONFIGURE_ID, "Configure settings", "primary")]
|
|
470
|
+
: []),
|
|
471
|
+
button(BACK_TO_MODULES_ID, "All modules", "secondary"),
|
|
472
|
+
button(CANCEL_ID, "Close", "secondary"),
|
|
473
|
+
],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function configurationResponse(module, state) {
|
|
477
|
+
const settings = module.manifest.dashboard?.settings ?? [];
|
|
478
|
+
if (!module.configuration || settings.length === 0) {
|
|
479
|
+
throw new Error("Selected module has no configurable settings");
|
|
480
|
+
}
|
|
481
|
+
const pages = pageCount(settings.length);
|
|
482
|
+
const page = clampPage(numberState(state, "settingPage"), pages);
|
|
483
|
+
const visible = pageItems(settings, page);
|
|
484
|
+
const value = draft(state);
|
|
485
|
+
return {
|
|
486
|
+
ephemeral: true,
|
|
487
|
+
componentsV2: {
|
|
488
|
+
accentColor: VIOLET,
|
|
489
|
+
text: [
|
|
490
|
+
{
|
|
491
|
+
content: `## ${module.manifest.name} settings\nChoose a setting to edit. Changes remain private and unsaved until you select Save settings.` +
|
|
492
|
+
(pages > 1 ? ` Page ${String(page + 1)} of ${String(pages)}.` : ""),
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
selects: [
|
|
497
|
+
{
|
|
498
|
+
customId: SETTING_SELECT_ID,
|
|
499
|
+
placeholder: "Choose a setting",
|
|
500
|
+
options: visible.map((setting) => ({
|
|
501
|
+
label: setting.label,
|
|
502
|
+
value: setting.key,
|
|
503
|
+
description: `${settingStatus(setting, value[setting.key])} · ${truncate(setting.description, 85)}`,
|
|
504
|
+
})),
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
buttons: [
|
|
508
|
+
...paginationButtons(page, pages, SETTING_PREVIOUS_ID, SETTING_NEXT_ID),
|
|
509
|
+
button(SAVE_ID, "Save settings", "success"),
|
|
510
|
+
button(BACK_TO_MODULE_ID, "Back", "secondary"),
|
|
511
|
+
button(CANCEL_ID, "Cancel", "secondary"),
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function settingValueResponse(setting) {
|
|
516
|
+
const clear = setting.required
|
|
517
|
+
? []
|
|
518
|
+
: [button(CLEAR_ID, "Clear setting", "secondary")];
|
|
519
|
+
const base = {
|
|
520
|
+
ephemeral: true,
|
|
521
|
+
componentsV2: {
|
|
522
|
+
accentColor: VIOLET,
|
|
523
|
+
text: [{ content: `## ${setting.label}\n${setting.description}` }],
|
|
524
|
+
},
|
|
525
|
+
buttons: [
|
|
526
|
+
...clear,
|
|
527
|
+
button(CONFIGURE_ID, "Back to settings", "secondary"),
|
|
528
|
+
button(CANCEL_ID, "Cancel", "secondary"),
|
|
529
|
+
],
|
|
530
|
+
};
|
|
531
|
+
if (setting.type === "boolean") {
|
|
532
|
+
return {
|
|
533
|
+
...base,
|
|
534
|
+
selects: [
|
|
535
|
+
{
|
|
536
|
+
customId: VALUE_SELECT_ID,
|
|
537
|
+
placeholder: "Choose enabled or disabled",
|
|
538
|
+
options: [
|
|
539
|
+
{ label: "Enabled", value: "true" },
|
|
540
|
+
{ label: "Disabled", value: "false" },
|
|
541
|
+
],
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (setting.type === "select") {
|
|
547
|
+
return {
|
|
548
|
+
...base,
|
|
549
|
+
selects: [
|
|
550
|
+
{
|
|
551
|
+
customId: VALUE_SELECT_ID,
|
|
552
|
+
placeholder: "Choose an option",
|
|
553
|
+
options: setting.options,
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (setting.type === "discord-channel") {
|
|
559
|
+
return {
|
|
560
|
+
...base,
|
|
561
|
+
selects: [
|
|
562
|
+
{
|
|
563
|
+
type: "discord-channel",
|
|
564
|
+
customId: VALUE_SELECT_ID,
|
|
565
|
+
placeholder: "Choose a channel",
|
|
566
|
+
channelTypes: setting.channelTypes,
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
if (setting.type === "discord-role") {
|
|
572
|
+
return {
|
|
573
|
+
...base,
|
|
574
|
+
selects: [
|
|
575
|
+
{
|
|
576
|
+
type: "discord-role",
|
|
577
|
+
customId: VALUE_SELECT_ID,
|
|
578
|
+
placeholder: setting.multiple ? "Choose roles" : "Choose a role",
|
|
579
|
+
minValues: 1,
|
|
580
|
+
maxValues: setting.multiple ? 25 : 1,
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
throw new Error("Selected setting requires a modal");
|
|
586
|
+
}
|
|
587
|
+
function settingModal(setting, values) {
|
|
588
|
+
const current = values[setting.key];
|
|
589
|
+
return {
|
|
590
|
+
customId: VALUE_MODAL_ID,
|
|
591
|
+
title: truncate(setting.label, 45),
|
|
592
|
+
text: [{ content: setting.description, markdown: false }],
|
|
593
|
+
fields: [
|
|
594
|
+
{
|
|
595
|
+
customId: VALUE_FIELD_ID,
|
|
596
|
+
label: setting.type === "integer" ? "Whole number" : setting.label,
|
|
597
|
+
...(setting.type === "integer"
|
|
598
|
+
? {
|
|
599
|
+
description: `From ${String(setting.minimum)} to ${String(setting.maximum)}.`,
|
|
600
|
+
}
|
|
601
|
+
: {}),
|
|
602
|
+
style: setting.type === "string" && setting.maxLength > 200
|
|
603
|
+
? "paragraph"
|
|
604
|
+
: "short",
|
|
605
|
+
required: setting.required,
|
|
606
|
+
minLength: setting.type === "string" ? setting.minLength : 1,
|
|
607
|
+
maxLength: setting.type === "string" ? setting.maxLength : 30,
|
|
608
|
+
...(setting.type === "string" && setting.placeholder
|
|
609
|
+
? { placeholder: setting.placeholder }
|
|
610
|
+
: {}),
|
|
611
|
+
...(typeof current === "string" || typeof current === "number"
|
|
612
|
+
? { value: String(current) }
|
|
613
|
+
: {}),
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function initialDraft(module, existing) {
|
|
619
|
+
const result = { ...(existing ?? {}) };
|
|
620
|
+
for (const setting of module.manifest.dashboard?.settings ?? []) {
|
|
621
|
+
if (result[setting.key] !== undefined)
|
|
622
|
+
continue;
|
|
623
|
+
if ("default" in setting && setting.default !== undefined) {
|
|
624
|
+
result[setting.key] = setting.default;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (!setting.required)
|
|
628
|
+
result[setting.key] = emptyValue(setting);
|
|
629
|
+
}
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
function emptyValue(setting) {
|
|
633
|
+
if (setting.type === "discord-role" && setting.multiple)
|
|
634
|
+
return [];
|
|
635
|
+
if (setting.type === "string" ||
|
|
636
|
+
setting.type === "discord-channel" ||
|
|
637
|
+
setting.type === "discord-role") {
|
|
638
|
+
return "";
|
|
639
|
+
}
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
function selectedValue(setting, values) {
|
|
643
|
+
if (setting.type === "boolean") {
|
|
644
|
+
if (values.length !== 1 || !["true", "false"].includes(values[0])) {
|
|
645
|
+
throw new Error("Selected boolean value is invalid");
|
|
646
|
+
}
|
|
647
|
+
return values[0] === "true";
|
|
648
|
+
}
|
|
649
|
+
if (setting.type === "select") {
|
|
650
|
+
if (values.length !== 1 ||
|
|
651
|
+
!setting.options.some((option) => option.value === values[0])) {
|
|
652
|
+
throw new Error("Selected option is unavailable");
|
|
653
|
+
}
|
|
654
|
+
return values[0];
|
|
655
|
+
}
|
|
656
|
+
if (setting.type === "discord-channel") {
|
|
657
|
+
if (values.length !== 1 || !isSnowflake(values[0])) {
|
|
658
|
+
throw new Error("Selected channel is invalid");
|
|
659
|
+
}
|
|
660
|
+
return values[0];
|
|
661
|
+
}
|
|
662
|
+
if (setting.type === "discord-role") {
|
|
663
|
+
if (values.length < 1 ||
|
|
664
|
+
values.length > (setting.multiple ? 25 : 1) ||
|
|
665
|
+
new Set(values).size !== values.length ||
|
|
666
|
+
values.some((value) => !isSnowflake(value))) {
|
|
667
|
+
throw new Error("Selected role value is invalid");
|
|
668
|
+
}
|
|
669
|
+
return setting.multiple ? [...values] : values[0];
|
|
670
|
+
}
|
|
671
|
+
throw new Error("Selected setting does not use a selector");
|
|
672
|
+
}
|
|
673
|
+
function parseModalValue(setting, input) {
|
|
674
|
+
const value = input.normalize("NFKC").trim();
|
|
675
|
+
if (setting.type === "string") {
|
|
676
|
+
if (!setting.required && value.length === 0)
|
|
677
|
+
return { value: "" };
|
|
678
|
+
if (value.length < setting.minLength || value.length > setting.maxLength) {
|
|
679
|
+
return {
|
|
680
|
+
problem: `${setting.label} must contain ${String(setting.minLength)} to ${String(setting.maxLength)} characters.`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
return { value };
|
|
684
|
+
}
|
|
685
|
+
if (!/^-?\d+$/u.test(value)) {
|
|
686
|
+
return { problem: `${setting.label} must be a whole number.` };
|
|
687
|
+
}
|
|
688
|
+
const number = Number(value);
|
|
689
|
+
if (!Number.isSafeInteger(number) ||
|
|
690
|
+
number < setting.minimum ||
|
|
691
|
+
number > setting.maximum) {
|
|
692
|
+
return {
|
|
693
|
+
problem: `${setting.label} must be from ${String(setting.minimum)} to ${String(setting.maximum)}.`,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return { value: number };
|
|
697
|
+
}
|
|
698
|
+
function validateDraft(module, values) {
|
|
699
|
+
for (const setting of module.manifest.dashboard?.settings ?? []) {
|
|
700
|
+
const value = values[setting.key];
|
|
701
|
+
if (!setting.required)
|
|
702
|
+
continue;
|
|
703
|
+
const missing = value === undefined ||
|
|
704
|
+
value === null ||
|
|
705
|
+
value === "" ||
|
|
706
|
+
(Array.isArray(value) && value.length === 0);
|
|
707
|
+
if (missing)
|
|
708
|
+
return `${setting.label} is required before settings can be saved.`;
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
async function validateDiscordSettings(directory, guildId, module, values) {
|
|
713
|
+
const settings = module.manifest.dashboard?.settings ?? [];
|
|
714
|
+
if (!settings.some((setting) => setting.type === "discord-channel" || setting.type === "discord-role")) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
const resources = await directory.getGuildResources(guildId);
|
|
718
|
+
const channels = new Map(resources.channels.map((channel) => [channel.id, channel]));
|
|
719
|
+
const roles = new Set(resources.roles.map((role) => role.id));
|
|
720
|
+
for (const setting of settings) {
|
|
721
|
+
const configured = values[setting.key];
|
|
722
|
+
if (configured === "" || configured === undefined)
|
|
723
|
+
continue;
|
|
724
|
+
if (setting.type === "discord-channel") {
|
|
725
|
+
const channel = typeof configured === "string" ? channels.get(configured) : undefined;
|
|
726
|
+
if (!channel || !setting.channelTypes.includes(channel.type)) {
|
|
727
|
+
return `${setting.label} must be an available supported Discord channel.`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (setting.type === "discord-role") {
|
|
731
|
+
if (setting.multiple !== Array.isArray(configured)) {
|
|
732
|
+
return `${setting.label} has an invalid Discord role selection.`;
|
|
733
|
+
}
|
|
734
|
+
const configuredRoles = Array.isArray(configured)
|
|
735
|
+
? configured
|
|
736
|
+
: [configured];
|
|
737
|
+
if (configuredRoles.some((roleId) => typeof roleId !== "string" || !roles.has(roleId))) {
|
|
738
|
+
return `${setting.label} must contain available Discord roles.`;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
function validationResponse(description) {
|
|
745
|
+
return {
|
|
746
|
+
ephemeral: true,
|
|
747
|
+
componentsV2: {
|
|
748
|
+
accentColor: DANGER,
|
|
749
|
+
text: [{ content: `## Settings not changed\n${description}` }],
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function respond(context, response) {
|
|
754
|
+
return context.updateResponse
|
|
755
|
+
? context.updateResponse(response)
|
|
756
|
+
: context.reply(response);
|
|
757
|
+
}
|
|
758
|
+
function selectedModule(state, modules) {
|
|
759
|
+
const module = modules.get(stringState(state, "moduleId"));
|
|
760
|
+
if (!module)
|
|
761
|
+
throw new Error("Selected module is unavailable");
|
|
762
|
+
return module;
|
|
763
|
+
}
|
|
764
|
+
function selectedSetting(module, key) {
|
|
765
|
+
const setting = module.manifest.dashboard?.settings.find((candidate) => candidate.key === key);
|
|
766
|
+
if (!setting)
|
|
767
|
+
throw new Error("Selected setting is unavailable");
|
|
768
|
+
return setting;
|
|
769
|
+
}
|
|
770
|
+
function settingStatus(setting, value) {
|
|
771
|
+
if (value === undefined ||
|
|
772
|
+
value === "" ||
|
|
773
|
+
(Array.isArray(value) && !value.length)) {
|
|
774
|
+
return setting.required ? "Required" : "Not set";
|
|
775
|
+
}
|
|
776
|
+
if (setting.type === "boolean")
|
|
777
|
+
return value === true ? "Enabled" : "Disabled";
|
|
778
|
+
if (setting.type === "discord-channel")
|
|
779
|
+
return "Channel selected";
|
|
780
|
+
if (setting.type === "discord-role") {
|
|
781
|
+
return Array.isArray(value)
|
|
782
|
+
? `${String(value.length)} roles selected`
|
|
783
|
+
: "Role selected";
|
|
784
|
+
}
|
|
785
|
+
return "Configured";
|
|
786
|
+
}
|
|
787
|
+
async function getSession(context) {
|
|
788
|
+
if (!context.serverId)
|
|
789
|
+
return null;
|
|
790
|
+
const session = await context.services
|
|
791
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
792
|
+
.getFor(context.serverId, context.userId, SESSION_PURPOSE);
|
|
793
|
+
if (!session)
|
|
794
|
+
throw new Error("Management session expired; run /helyx again");
|
|
795
|
+
return session;
|
|
796
|
+
}
|
|
797
|
+
function updateSession(context, session, state) {
|
|
798
|
+
return context.services
|
|
799
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
800
|
+
.update({
|
|
801
|
+
sessionId: session.sessionId,
|
|
802
|
+
state,
|
|
803
|
+
expectedVersion: session.version,
|
|
804
|
+
expiresAt: sessionExpiry(),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
function draft(state) {
|
|
808
|
+
const value = state.draft;
|
|
809
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
810
|
+
throw new Error("Management session is invalid");
|
|
811
|
+
}
|
|
812
|
+
return value;
|
|
813
|
+
}
|
|
814
|
+
function stringState(state, key) {
|
|
815
|
+
const value = state[key];
|
|
816
|
+
if (typeof value !== "string")
|
|
817
|
+
throw new Error("Management session is invalid");
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
function numberState(state, key) {
|
|
821
|
+
const value = state[key];
|
|
822
|
+
if (value === undefined)
|
|
823
|
+
return 0;
|
|
824
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
825
|
+
throw new Error("Management session is invalid");
|
|
826
|
+
}
|
|
827
|
+
return value;
|
|
828
|
+
}
|
|
829
|
+
function optionalBigIntState(state, key) {
|
|
830
|
+
const value = state[key];
|
|
831
|
+
if (value === null || value === undefined)
|
|
832
|
+
return null;
|
|
833
|
+
if (typeof value !== "string" || !/^\d+$/u.test(value)) {
|
|
834
|
+
throw new Error("Management session is invalid");
|
|
835
|
+
}
|
|
836
|
+
return BigInt(value);
|
|
837
|
+
}
|
|
838
|
+
function pageItems(items, page) {
|
|
839
|
+
return items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
840
|
+
}
|
|
841
|
+
function pageCount(itemCount) {
|
|
842
|
+
return Math.max(1, Math.ceil(itemCount / PAGE_SIZE));
|
|
843
|
+
}
|
|
844
|
+
function clampPage(page, pages) {
|
|
845
|
+
return Math.max(0, Math.min(page, pages - 1));
|
|
846
|
+
}
|
|
847
|
+
function paginationButtons(page, pages, previousId, nextId) {
|
|
848
|
+
return [
|
|
849
|
+
...(page > 0 ? [button(previousId, "Previous page", "secondary")] : []),
|
|
850
|
+
...(page + 1 < pages ? [button(nextId, "Next page", "secondary")] : []),
|
|
851
|
+
];
|
|
852
|
+
}
|
|
853
|
+
function button(customId, label, style) {
|
|
854
|
+
return { type: "button", customId, label, style };
|
|
855
|
+
}
|
|
856
|
+
function sessionExpiry() {
|
|
857
|
+
return new Date(Date.now() + SESSION_LIFETIME_MS);
|
|
858
|
+
}
|
|
859
|
+
function isSnowflake(value) {
|
|
860
|
+
return /^\d{17,20}$/u.test(value);
|
|
861
|
+
}
|
|
862
|
+
function truncate(value, maximum) {
|
|
863
|
+
return value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`;
|
|
864
|
+
}
|
|
865
|
+
//# sourceMappingURL=management-module.js.map
|