@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.
@@ -0,0 +1,497 @@
1
+ import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { PermissionFlagsBits } from "discord.js";
3
+ const MODULE_SELECT_ID = "helyx.permissions.module";
4
+ const TARGET_SELECT_ID = "helyx.permissions.target";
5
+ const MODULE_PREVIOUS_ID = "helyx.permissions.module.previous";
6
+ const MODULE_NEXT_ID = "helyx.permissions.module.next";
7
+ const TARGET_PREVIOUS_ID = "helyx.permissions.target.previous";
8
+ const TARGET_NEXT_ID = "helyx.permissions.target.next";
9
+ const POLICY_MODAL_ID = "helyx.permissions.policy";
10
+ const EVERYONE_FIELD_ID = "helyx.permissions.policy.everyone";
11
+ const REQUIRED_PERMISSIONS_FIELD_ID = "helyx.permissions.policy.required";
12
+ const ALLOWED_MENTIONABLES_FIELD_ID = "helyx.permissions.policy.allowed";
13
+ const DENIED_MENTIONABLES_FIELD_ID = "helyx.permissions.policy.denied";
14
+ const SESSION_PURPOSE = "permissions.manage";
15
+ const MODULE_PAGE_SIZE = 25;
16
+ const TARGET_PAGE_SIZE = 24;
17
+ const VIOLET = 0x7c3aed;
18
+ export function createPermissionsModule(configurableModules) {
19
+ const modules = new Map(configurableModules.map((module) => [module.manifest.id, module]));
20
+ const commandModules = [...modules.values()]
21
+ .filter((module) => commandTargets(module).length > 0)
22
+ .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
23
+ return defineModule({
24
+ manifest: {
25
+ manifestVersion: "0.1",
26
+ id: "helyx.permissions",
27
+ name: "Permissions",
28
+ version: "0.1.0",
29
+ description: "Configures Helyx module and command access policies.",
30
+ entry: "./dist/permissions-module.js",
31
+ framework: "^0.1.0",
32
+ dependencies: [],
33
+ permissions: [],
34
+ contributions: { commands: ["permissions"], events: [] },
35
+ },
36
+ defaultPermissions: {
37
+ module: restrictedPolicy(),
38
+ commands: {
39
+ permissions: restrictedPolicy(),
40
+ },
41
+ },
42
+ interactions: {
43
+ commands: [
44
+ {
45
+ name: "permissions",
46
+ description: "Configure access to Helyx modules and commands",
47
+ registration: "global",
48
+ contexts: ["guild"],
49
+ integrationTypes: ["guild_install"],
50
+ rateLimit: { scope: "user", limit: 20, windowSeconds: 60 },
51
+ async execute(context) {
52
+ if (!context.serverId)
53
+ return;
54
+ if (commandModules.length === 0) {
55
+ await context.reply({
56
+ ephemeral: true,
57
+ embed: {
58
+ title: "No configurable modules",
59
+ description: "Install a feature module that contributes Discord commands first.",
60
+ color: 0xdc2626,
61
+ },
62
+ });
63
+ return;
64
+ }
65
+ await context.services
66
+ .get(HELYX_SERVICE_NAMES.sessions)
67
+ .create({
68
+ guildId: context.serverId,
69
+ userId: context.userId,
70
+ purpose: SESSION_PURPOSE,
71
+ state: { modulePage: 0 },
72
+ expiresAt: new Date(Date.now() + 10 * 60 * 1_000),
73
+ replace: true,
74
+ });
75
+ await context.reply(modulePickerResponse(commandModules, 0));
76
+ },
77
+ },
78
+ ],
79
+ selects: [
80
+ {
81
+ customId: MODULE_SELECT_ID,
82
+ async execute(context) {
83
+ const session = await getSession(context);
84
+ if (!session || !context.serverId)
85
+ return;
86
+ const moduleId = context.selectedValues[0];
87
+ const module = moduleId
88
+ ? commandModules.find((candidate) => candidate.manifest.id === moduleId)
89
+ : undefined;
90
+ if (!module)
91
+ throw new Error("Selected module is unavailable");
92
+ const targets = commandTargets(module);
93
+ await context.services
94
+ .get(HELYX_SERVICE_NAMES.sessions)
95
+ .update({
96
+ sessionId: session.sessionId,
97
+ state: {
98
+ ...session.state,
99
+ moduleId,
100
+ targetPage: 0,
101
+ },
102
+ expectedVersion: session.version,
103
+ });
104
+ await context.reply(targetPickerResponse(module, targets, 0));
105
+ },
106
+ },
107
+ {
108
+ customId: TARGET_SELECT_ID,
109
+ async execute(context) {
110
+ const session = await getSession(context);
111
+ if (!session || !context.serverId)
112
+ return;
113
+ const moduleId = stringState(session.state, "moduleId");
114
+ const target = context.selectedValues[0];
115
+ const module = modules.get(moduleId);
116
+ if (!module || !target)
117
+ throw new Error("Permission target expired");
118
+ const commandId = target === "__module__" ? undefined : target;
119
+ if (commandId && !commandTargets(module).includes(commandId)) {
120
+ throw new Error("Selected command is unavailable");
121
+ }
122
+ const current = await context.services
123
+ .get(HELYX_SERVICE_NAMES.permissions)
124
+ .getPolicy(context.serverId, moduleId, commandId);
125
+ const allowedDefaults = mentionableDefaults(current.policy.allowedUserIds, current.policy.allowedRoleIds);
126
+ const deniedDefaults = mentionableDefaults(current.policy.deniedUserIds, current.policy.deniedRoleIds);
127
+ if (allowedDefaults.length > 25 || deniedDefaults.length > 25) {
128
+ await context.reply({
129
+ ephemeral: true,
130
+ componentsV2: {
131
+ accentColor: 0xdc2626,
132
+ text: [
133
+ {
134
+ content: "## Policy is too large for Discord\nA Discord modal can show at most 25 allowed and 25 denied users or roles. Use the Helyx dashboard to edit this policy without losing existing entries.",
135
+ },
136
+ ],
137
+ },
138
+ });
139
+ return;
140
+ }
141
+ await context.services
142
+ .get(HELYX_SERVICE_NAMES.sessions)
143
+ .update({
144
+ sessionId: session.sessionId,
145
+ state: {
146
+ moduleId,
147
+ ...(commandId ? { commandId } : {}),
148
+ expectedPolicyVersion: current.version?.toString() ?? null,
149
+ },
150
+ expectedVersion: session.version,
151
+ });
152
+ await context.showModal({
153
+ customId: POLICY_MODAL_ID,
154
+ title: "Edit permission policy",
155
+ fields: [
156
+ {
157
+ type: "string-select",
158
+ customId: EVERYONE_FIELD_ID,
159
+ label: "Allow everyone to use the command?",
160
+ description: "Overrides below still take priority over this setting.",
161
+ required: true,
162
+ minValues: 1,
163
+ maxValues: 1,
164
+ options: [
165
+ {
166
+ label: "Yes",
167
+ value: "yes",
168
+ default: current.policy.everyone,
169
+ },
170
+ {
171
+ label: "No",
172
+ value: "no",
173
+ default: !current.policy.everyone,
174
+ },
175
+ ],
176
+ },
177
+ {
178
+ customId: REQUIRED_PERMISSIONS_FIELD_ID,
179
+ label: "Required Discord permissions",
180
+ description: "Optional permission names; separate multiple values with commas.",
181
+ style: "paragraph",
182
+ value: current.policy.requiredPermissions.join(", "),
183
+ placeholder: "MANAGE_GUILD, MODERATE_MEMBERS",
184
+ maxLength: 1000,
185
+ },
186
+ {
187
+ type: "discord-mentionable",
188
+ customId: ALLOWED_MENTIONABLES_FIELD_ID,
189
+ label: "Allowed users and roles",
190
+ description: "Users override role denials; allowed roles bypass general rules.",
191
+ placeholder: "Choose allowed users and roles",
192
+ required: false,
193
+ minValues: 0,
194
+ maxValues: 25,
195
+ defaultValues: allowedDefaults,
196
+ },
197
+ {
198
+ type: "discord-mentionable",
199
+ customId: DENIED_MENTIONABLES_FIELD_ID,
200
+ label: "Denied users and roles",
201
+ description: "User denials take priority; role denials apply next.",
202
+ placeholder: "Choose denied users and roles",
203
+ required: false,
204
+ minValues: 0,
205
+ maxValues: 25,
206
+ defaultValues: deniedDefaults,
207
+ },
208
+ ],
209
+ });
210
+ },
211
+ },
212
+ ],
213
+ buttons: [
214
+ modulePageButton(MODULE_PREVIOUS_ID, -1),
215
+ modulePageButton(MODULE_NEXT_ID, 1),
216
+ targetPageButton(TARGET_PREVIOUS_ID, -1),
217
+ targetPageButton(TARGET_NEXT_ID, 1),
218
+ ],
219
+ modals: [
220
+ {
221
+ customId: POLICY_MODAL_ID,
222
+ async execute(context) {
223
+ const session = await getSession(context);
224
+ if (!session || !context.serverId)
225
+ return;
226
+ const moduleId = stringState(session.state, "moduleId");
227
+ const commandId = optionalStringState(session.state, "commandId");
228
+ const expected = session.state.expectedPolicyVersion;
229
+ const everyoneValues = context.getStringSelectValues(EVERYONE_FIELD_ID);
230
+ if (everyoneValues.length !== 1 ||
231
+ !["yes", "no"].includes(everyoneValues[0])) {
232
+ throw new Error("Allow everyone selection is invalid");
233
+ }
234
+ const allowed = context.getSelectedMentionables(ALLOWED_MENTIONABLES_FIELD_ID);
235
+ const denied = context.getSelectedMentionables(DENIED_MENTIONABLES_FIELD_ID);
236
+ assertNoMentionableConflicts(allowed, denied);
237
+ const policy = {
238
+ everyone: everyoneValues[0] === "yes",
239
+ requiredPermissions: parsePermissions(context.getField(REQUIRED_PERMISSIONS_FIELD_ID)),
240
+ allowedRoleIds: [...allowed.roleIds],
241
+ deniedRoleIds: [...denied.roleIds],
242
+ allowedUserIds: [...allowed.userIds],
243
+ deniedUserIds: [...denied.userIds],
244
+ };
245
+ await context.services
246
+ .get(HELYX_SERVICE_NAMES.permissions)
247
+ .setPolicy({
248
+ guildId: context.serverId,
249
+ moduleId,
250
+ ...(commandId ? { commandId } : {}),
251
+ policy,
252
+ expectedVersion: typeof expected === "string" ? BigInt(expected) : null,
253
+ context: {
254
+ actorUserId: context.userId,
255
+ correlationId: context.interactionId,
256
+ source: "discord",
257
+ },
258
+ });
259
+ await context.services
260
+ .get(HELYX_SERVICE_NAMES.sessions)
261
+ .delete(session.sessionId);
262
+ await context.reply({
263
+ ephemeral: true,
264
+ embed: {
265
+ title: "Permissions updated",
266
+ description: commandId
267
+ ? "The override for `" + commandId + "` was updated."
268
+ : "The module policy for `" + moduleId + "` was updated.",
269
+ color: 0x16a34a,
270
+ },
271
+ });
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ start: () => Promise.resolve(),
277
+ stop: () => Promise.resolve(),
278
+ });
279
+ function modulePageButton(customId, change) {
280
+ return {
281
+ customId,
282
+ async execute(context) {
283
+ const session = await getSession(context);
284
+ if (!session)
285
+ return;
286
+ const requested = numberState(session.state, "modulePage") + change;
287
+ const page = clampPage(requested, pageCount(commandModules.length, MODULE_PAGE_SIZE));
288
+ await context.services
289
+ .get(HELYX_SERVICE_NAMES.sessions)
290
+ .update({
291
+ sessionId: session.sessionId,
292
+ state: { ...session.state, modulePage: page },
293
+ expectedVersion: session.version,
294
+ });
295
+ await context.reply(modulePickerResponse(commandModules, page));
296
+ },
297
+ };
298
+ }
299
+ function targetPageButton(customId, change) {
300
+ return {
301
+ customId,
302
+ async execute(context) {
303
+ const session = await getSession(context);
304
+ if (!session)
305
+ return;
306
+ const moduleId = stringState(session.state, "moduleId");
307
+ const module = commandModules.find((candidate) => candidate.manifest.id === moduleId);
308
+ if (!module)
309
+ throw new Error("Selected module is unavailable");
310
+ const targets = commandTargets(module);
311
+ const requested = numberState(session.state, "targetPage") + change;
312
+ const page = clampPage(requested, pageCount(targets.length, TARGET_PAGE_SIZE));
313
+ await context.services
314
+ .get(HELYX_SERVICE_NAMES.sessions)
315
+ .update({
316
+ sessionId: session.sessionId,
317
+ state: { ...session.state, targetPage: page },
318
+ expectedVersion: session.version,
319
+ });
320
+ await context.reply(targetPickerResponse(module, targets, page));
321
+ },
322
+ };
323
+ }
324
+ }
325
+ function restrictedPolicy() {
326
+ return {
327
+ everyone: false,
328
+ requiredPermissions: ["MANAGE_GUILD"],
329
+ allowedRoleIds: [],
330
+ deniedRoleIds: [],
331
+ allowedUserIds: [],
332
+ deniedUserIds: [],
333
+ };
334
+ }
335
+ function commandTargets(module) {
336
+ return (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
337
+ ? command.subcommands.map((subcommand) => `${command.name}.${subcommand.name}`)
338
+ : [command.name]);
339
+ }
340
+ function modulePickerResponse(modules, requestedPage) {
341
+ const pages = pageCount(modules.length, MODULE_PAGE_SIZE);
342
+ const page = clampPage(requestedPage, pages);
343
+ const visible = pageItems(modules, page, MODULE_PAGE_SIZE);
344
+ return {
345
+ ephemeral: true,
346
+ embed: {
347
+ title: "Module permissions",
348
+ description: "Choose an installed module. Disabled modules can be configured before they are enabled. Module-level policy is inherited unless a command override exists." +
349
+ (pages > 1 ? ` Page ${String(page + 1)} of ${String(pages)}.` : ""),
350
+ color: VIOLET,
351
+ },
352
+ selects: [
353
+ {
354
+ customId: MODULE_SELECT_ID,
355
+ placeholder: "Choose a module",
356
+ options: visible.map((module) => ({
357
+ label: module.manifest.name,
358
+ value: module.manifest.id,
359
+ description: module.manifest.description.slice(0, 100),
360
+ })),
361
+ },
362
+ ],
363
+ buttons: paginationButtons(page, pages, MODULE_PREVIOUS_ID, MODULE_NEXT_ID),
364
+ };
365
+ }
366
+ function targetPickerResponse(module, targets, requestedPage) {
367
+ const pages = pageCount(targets.length, TARGET_PAGE_SIZE);
368
+ const page = clampPage(requestedPage, pages);
369
+ const visible = pageItems(targets, page, TARGET_PAGE_SIZE);
370
+ return {
371
+ ephemeral: true,
372
+ embed: {
373
+ title: `${module.manifest.name} permissions`,
374
+ description: "Choose the whole module or one command. Command policies override the module policy." +
375
+ (pages > 1 ? ` Page ${String(page + 1)} of ${String(pages)}.` : ""),
376
+ color: VIOLET,
377
+ },
378
+ selects: [
379
+ {
380
+ customId: TARGET_SELECT_ID,
381
+ placeholder: "Choose a permission target",
382
+ options: [
383
+ {
384
+ label: "Entire module",
385
+ value: "__module__",
386
+ description: "Default policy for every command",
387
+ },
388
+ ...visible.map((target) => ({
389
+ label: `/${target.replaceAll(".", " ")}`,
390
+ value: target,
391
+ })),
392
+ ],
393
+ },
394
+ ],
395
+ buttons: paginationButtons(page, pages, TARGET_PREVIOUS_ID, TARGET_NEXT_ID),
396
+ };
397
+ }
398
+ function paginationButtons(page, pages, previousId, nextId) {
399
+ return [
400
+ ...(page > 0
401
+ ? [
402
+ {
403
+ type: "button",
404
+ customId: previousId,
405
+ label: "Previous page",
406
+ style: "secondary",
407
+ },
408
+ ]
409
+ : []),
410
+ ...(page + 1 < pages
411
+ ? [
412
+ {
413
+ type: "button",
414
+ customId: nextId,
415
+ label: "Next page",
416
+ style: "secondary",
417
+ },
418
+ ]
419
+ : []),
420
+ ];
421
+ }
422
+ function pageItems(items, page, size) {
423
+ return items.slice(page * size, (page + 1) * size);
424
+ }
425
+ function pageCount(itemCount, size) {
426
+ return Math.max(1, Math.ceil(itemCount / size));
427
+ }
428
+ function clampPage(page, pages) {
429
+ return Math.max(0, Math.min(page, pages - 1));
430
+ }
431
+ async function getSession(context) {
432
+ if (!context.serverId)
433
+ return null;
434
+ const session = await context.services
435
+ .get(HELYX_SERVICE_NAMES.sessions)
436
+ .getFor(context.serverId, context.userId, SESSION_PURPOSE);
437
+ if (!session)
438
+ throw new Error("Permission session expired; run /permissions again");
439
+ return session;
440
+ }
441
+ function stringState(state, field) {
442
+ const value = state[field];
443
+ if (typeof value !== "string")
444
+ throw new Error("Permission session is invalid");
445
+ return value;
446
+ }
447
+ function numberState(state, field) {
448
+ const value = state[field];
449
+ if (value === undefined)
450
+ return 0;
451
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
452
+ throw new Error("Permission session is invalid");
453
+ }
454
+ return value;
455
+ }
456
+ function optionalStringState(state, field) {
457
+ const value = state[field];
458
+ return typeof value === "string" ? value : undefined;
459
+ }
460
+ function mentionableDefaults(userIds, roleIds) {
461
+ return [
462
+ ...userIds.map((id) => ({ type: "user", id })),
463
+ ...roleIds.map((id) => ({ type: "role", id })),
464
+ ];
465
+ }
466
+ function assertNoMentionableConflicts(allowed, denied) {
467
+ const deniedUsers = new Set(denied.userIds);
468
+ const deniedRoles = new Set(denied.roleIds);
469
+ if (allowed.userIds.some((id) => deniedUsers.has(id)) ||
470
+ allowed.roleIds.some((id) => deniedRoles.has(id))) {
471
+ throw new Error("A user or role cannot be both allowed and denied");
472
+ }
473
+ }
474
+ function parsePermissions(value) {
475
+ const permissions = splitValues(value).map((item) => item.toUpperCase());
476
+ for (const permission of permissions) {
477
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(permission)) {
478
+ throw new Error(`Invalid Discord permission: ${permission}`);
479
+ }
480
+ const discordName = permission
481
+ .toLowerCase()
482
+ .split("_")
483
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
484
+ .join("");
485
+ if (!(discordName in PermissionFlagsBits)) {
486
+ throw new Error(`Unknown Discord permission: ${permission}`);
487
+ }
488
+ }
489
+ return [...new Set(permissions)];
490
+ }
491
+ function splitValues(value) {
492
+ return value
493
+ .split(",")
494
+ .map((item) => item.trim())
495
+ .filter(Boolean);
496
+ }
497
+ //# sourceMappingURL=permissions-module.js.map
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@helyx/bot",
3
+ "version": "0.1.0",
4
+ "description": "Production Discord runtime for the modular Helyx platform.",
5
+ "keywords": [
6
+ "helyx",
7
+ "discord",
8
+ "bot",
9
+ "modular"
10
+ ],
11
+ "private": false,
12
+ "license": "SEE LICENSE IN LICENSE",
13
+ "homepage": "https://helyx.gg/",
14
+ "bugs": {
15
+ "url": "https://github.com/ZyC0R3/Helyx/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ZyC0R3/Helyx.git",
20
+ "directory": "apps/bot"
21
+ },
22
+ "engines": {
23
+ "node": ">=24 <27",
24
+ "npm": ">=11"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
30
+ "type": "module",
31
+ "files": [
32
+ "dist",
33
+ "!dist/*.tsbuildinfo",
34
+ "!dist/**/*.map",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "default": "./dist/index.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -b",
46
+ "dev": "node --env-file-if-exists=../../.env --import tsx src/index.ts",
47
+ "start": "node --env-file-if-exists=../../.env dist/index.js"
48
+ },
49
+ "dependencies": {
50
+ "@helyx/config": "0.1.0",
51
+ "@helyx/core": "0.1.0",
52
+ "@helyx/database": "0.1.0",
53
+ "@helyx/framework": "0.1.0",
54
+ "@helyx/module-manifest": "0.1.0",
55
+ "@helyx/platform": "0.1.0",
56
+ "@helyx/sdk": "0.2.0",
57
+ "discord.js": "14.27.0",
58
+ "fastify": "5.10.0",
59
+ "pino": "10.3.1",
60
+ "zod": "4.4.3"
61
+ }
62
+ }