@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,663 @@
|
|
|
1
|
+
import { AuditRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
|
|
2
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
|
|
3
|
+
import { ChannelType } from "discord.js";
|
|
4
|
+
import Fastify, {} from "fastify";
|
|
5
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { z, ZodError } from "zod";
|
|
7
|
+
import { deleteChannelMessage as deleteDiscordChannelMessage, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
|
|
8
|
+
const snowflake = z.string().regex(/^\d{17,20}$/u);
|
|
9
|
+
const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
|
|
10
|
+
const configurationValue = z.record(z.string(), z.union([
|
|
11
|
+
z.string(),
|
|
12
|
+
z.boolean(),
|
|
13
|
+
z.number().finite(),
|
|
14
|
+
z.array(z.string()).max(100),
|
|
15
|
+
]));
|
|
16
|
+
const permissionPreset = z.enum([
|
|
17
|
+
"everyone",
|
|
18
|
+
"moderators",
|
|
19
|
+
"administrators",
|
|
20
|
+
"custom",
|
|
21
|
+
]);
|
|
22
|
+
const dashboardPermissionPolicy = z.object({
|
|
23
|
+
preset: permissionPreset,
|
|
24
|
+
allowedRoleIds: z.array(snowflake).max(100),
|
|
25
|
+
deniedRoleIds: z.array(snowflake).max(100),
|
|
26
|
+
});
|
|
27
|
+
const permissionState = z.object({
|
|
28
|
+
version: z.number().int().min(0),
|
|
29
|
+
module: dashboardPermissionPolicy,
|
|
30
|
+
commands: z
|
|
31
|
+
.array(z.object({
|
|
32
|
+
commandId: z.string().min(1).max(100),
|
|
33
|
+
version: z.number().int().min(0),
|
|
34
|
+
enabled: z.boolean(),
|
|
35
|
+
policy: dashboardPermissionPolicy,
|
|
36
|
+
}))
|
|
37
|
+
.max(100),
|
|
38
|
+
});
|
|
39
|
+
const mutationContext = z.object({
|
|
40
|
+
actorUserId: snowflake,
|
|
41
|
+
correlationId: z.uuid(),
|
|
42
|
+
});
|
|
43
|
+
export class DiscordGuildResourceDirectory {
|
|
44
|
+
client;
|
|
45
|
+
constructor(client) {
|
|
46
|
+
this.client = client;
|
|
47
|
+
}
|
|
48
|
+
async getGuildResources(guildId) {
|
|
49
|
+
try {
|
|
50
|
+
const guild = await this.client.guilds.fetch(guildId);
|
|
51
|
+
const [channels, roles] = await Promise.all([
|
|
52
|
+
guild.channels.fetch(),
|
|
53
|
+
guild.roles.fetch(),
|
|
54
|
+
]);
|
|
55
|
+
return {
|
|
56
|
+
channels: [...channels.values()]
|
|
57
|
+
.filter((channel) => channel?.type === ChannelType.GuildText ||
|
|
58
|
+
channel?.type === ChannelType.GuildAnnouncement)
|
|
59
|
+
.map((channel) => ({
|
|
60
|
+
id: channel.id,
|
|
61
|
+
name: channel.name,
|
|
62
|
+
type: channel.type === ChannelType.GuildAnnouncement
|
|
63
|
+
? "guild_announcement"
|
|
64
|
+
: "guild_text",
|
|
65
|
+
}))
|
|
66
|
+
.sort((left, right) => left.name.localeCompare(right.name)),
|
|
67
|
+
roles: [...roles.values()]
|
|
68
|
+
.filter((role) => role.id !== guild.id && !role.managed)
|
|
69
|
+
.sort((left, right) => right.position - left.position)
|
|
70
|
+
.map((role) => ({
|
|
71
|
+
id: role.id,
|
|
72
|
+
name: role.name,
|
|
73
|
+
colour: role.hexColor,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isUnknownGuildError(error))
|
|
79
|
+
throw new BotControlNotFoundError();
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
sendToChannel(channelId, response) {
|
|
84
|
+
return sendDiscordMessageToChannel(this.client, channelId, response);
|
|
85
|
+
}
|
|
86
|
+
deleteChannelMessage(channelId, messageId) {
|
|
87
|
+
return deleteDiscordChannelMessage(this.client, channelId, messageId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class BotControlService {
|
|
91
|
+
database;
|
|
92
|
+
resources;
|
|
93
|
+
#modules;
|
|
94
|
+
#dashboardModules;
|
|
95
|
+
#configurations;
|
|
96
|
+
#installations;
|
|
97
|
+
#permissions;
|
|
98
|
+
#services;
|
|
99
|
+
constructor(database, modules, resources, services) {
|
|
100
|
+
this.database = database;
|
|
101
|
+
this.resources = resources;
|
|
102
|
+
this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
103
|
+
this.#dashboardModules = modules
|
|
104
|
+
.filter((module) => module.manifest.dashboard !== undefined)
|
|
105
|
+
.map(mapDashboardModule);
|
|
106
|
+
this.#configurations = services.get(HELYX_SERVICE_NAMES.configuration);
|
|
107
|
+
this.#installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
108
|
+
this.#permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
109
|
+
this.#services = services;
|
|
110
|
+
}
|
|
111
|
+
async getGuildState(guildId) {
|
|
112
|
+
const [resources, installedModules, storedLoggingPolicies] = await Promise.all([
|
|
113
|
+
this.resources.getGuildResources(guildId),
|
|
114
|
+
new InstallationRepository(this.database).listModules(guildId),
|
|
115
|
+
new ModuleLoggingPolicyRepository(this.database).listForGuild(guildId),
|
|
116
|
+
]);
|
|
117
|
+
const installed = new Map(installedModules.map((module) => [module.moduleId, module]));
|
|
118
|
+
const moduleStatuses = {};
|
|
119
|
+
const configurations = {};
|
|
120
|
+
const permissions = {};
|
|
121
|
+
const logging = {};
|
|
122
|
+
const loggingPolicies = new Map(storedLoggingPolicies.map((policy) => [
|
|
123
|
+
`${policy.sourceModuleId}:${policy.eventId}`,
|
|
124
|
+
policy,
|
|
125
|
+
]));
|
|
126
|
+
await Promise.all(this.#dashboardModules.map(async (dashboardModule) => {
|
|
127
|
+
const module = this.#requireModule(dashboardModule.id);
|
|
128
|
+
const installation = installed.get(dashboardModule.id);
|
|
129
|
+
moduleStatuses[dashboardModule.id] = installation
|
|
130
|
+
? installation.enabled
|
|
131
|
+
? "enabled"
|
|
132
|
+
: "disabled"
|
|
133
|
+
: "unavailable";
|
|
134
|
+
const [configuration, permission] = await Promise.all([
|
|
135
|
+
this.#configurations.get(guildId, dashboardModule.id),
|
|
136
|
+
this.#getPermissionState(guildId, module),
|
|
137
|
+
]);
|
|
138
|
+
if (configuration)
|
|
139
|
+
configurations[dashboardModule.id] = {
|
|
140
|
+
version: safeVersion(configuration.version),
|
|
141
|
+
value: configuration.value,
|
|
142
|
+
};
|
|
143
|
+
permissions[dashboardModule.id] = permission;
|
|
144
|
+
logging[dashboardModule.id] = dashboardModule.logEvents.map((event) => {
|
|
145
|
+
const stored = loggingPolicies.get(`${dashboardModule.id}:${event.id}`);
|
|
146
|
+
return {
|
|
147
|
+
eventId: event.id,
|
|
148
|
+
enabled: stored?.enabled ?? event.defaultEnabled,
|
|
149
|
+
version: stored ? safeVersion(stored.version) : 0,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}));
|
|
153
|
+
return {
|
|
154
|
+
...resources,
|
|
155
|
+
modules: structuredClone(this.#dashboardModules),
|
|
156
|
+
moduleStatuses,
|
|
157
|
+
configurations,
|
|
158
|
+
permissions,
|
|
159
|
+
logging,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async setModuleStatus(input) {
|
|
163
|
+
this.#requireDashboardModule(input.moduleId);
|
|
164
|
+
await this.#installations.setModuleEnabled({
|
|
165
|
+
guildId: input.guildId,
|
|
166
|
+
moduleId: input.moduleId,
|
|
167
|
+
enabled: input.enabled,
|
|
168
|
+
context: {
|
|
169
|
+
actorUserId: input.actorUserId,
|
|
170
|
+
correlationId: input.correlationId,
|
|
171
|
+
source: "dashboard",
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return this.getGuildState(input.guildId);
|
|
175
|
+
}
|
|
176
|
+
async updateConfiguration(input) {
|
|
177
|
+
const module = this.#requireDashboardModule(input.moduleId);
|
|
178
|
+
await this.#validateDiscordSettings(input.guildId, module, input.value);
|
|
179
|
+
const saved = await this.#configurations.update({
|
|
180
|
+
guildId: input.guildId,
|
|
181
|
+
moduleId: input.moduleId,
|
|
182
|
+
value: input.value,
|
|
183
|
+
expectedVersion: input.expectedVersion === null ? null : BigInt(input.expectedVersion),
|
|
184
|
+
context: {
|
|
185
|
+
actorUserId: input.actorUserId,
|
|
186
|
+
correlationId: input.correlationId,
|
|
187
|
+
source: "dashboard",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return { value: saved.value, version: safeVersion(saved.version) };
|
|
191
|
+
}
|
|
192
|
+
async updatePermissions(input) {
|
|
193
|
+
const module = this.#requireDashboardModule(input.moduleId);
|
|
194
|
+
const commands = dashboardCommands(module);
|
|
195
|
+
const supplied = new Map(input.state.commands.map((command) => [command.commandId, command]));
|
|
196
|
+
if (supplied.size !== input.state.commands.length ||
|
|
197
|
+
supplied.size !== commands.length ||
|
|
198
|
+
commands.some((command) => !supplied.has(command.id)))
|
|
199
|
+
throw new BotControlValidationError("Permission commands did not match the installed module");
|
|
200
|
+
await this.#validatePermissionRoles(input.guildId, input.state);
|
|
201
|
+
const currentModule = await this.#permissions.getPolicy(input.guildId, input.moduleId);
|
|
202
|
+
assertVersion(currentModule.version, input.state.version);
|
|
203
|
+
const currentCommands = new Map(await Promise.all(commands.map(async (command) => [
|
|
204
|
+
command.id,
|
|
205
|
+
await this.#permissions.getPolicy(input.guildId, input.moduleId, command.id),
|
|
206
|
+
])));
|
|
207
|
+
for (const command of commands)
|
|
208
|
+
assertVersion(currentCommands.get(command.id).version, supplied.get(command.id).version);
|
|
209
|
+
await withTransaction(this.database, async (connection) => {
|
|
210
|
+
const policies = new PermissionPolicyRepository(connection);
|
|
211
|
+
const audit = new AuditRepository(connection);
|
|
212
|
+
const savedModule = await policies.save({
|
|
213
|
+
guildId: input.guildId,
|
|
214
|
+
moduleId: input.moduleId,
|
|
215
|
+
policy: {
|
|
216
|
+
...fromDashboardPolicy(input.state.module, currentModule.policy),
|
|
217
|
+
},
|
|
218
|
+
expectedVersion: currentModule.version,
|
|
219
|
+
updatedBy: input.actorUserId,
|
|
220
|
+
});
|
|
221
|
+
await audit.append({
|
|
222
|
+
guildId: input.guildId,
|
|
223
|
+
actorUserId: input.actorUserId,
|
|
224
|
+
action: "module.permission.updated",
|
|
225
|
+
source: "dashboard",
|
|
226
|
+
correlationId: input.correlationId,
|
|
227
|
+
moduleId: input.moduleId,
|
|
228
|
+
afterState: { version: savedModule.version.toString() },
|
|
229
|
+
});
|
|
230
|
+
for (const command of commands) {
|
|
231
|
+
const requested = supplied.get(command.id);
|
|
232
|
+
const current = currentCommands.get(command.id);
|
|
233
|
+
if (requested.enabled) {
|
|
234
|
+
const saved = await policies.save({
|
|
235
|
+
guildId: input.guildId,
|
|
236
|
+
moduleId: input.moduleId,
|
|
237
|
+
commandId: command.id,
|
|
238
|
+
policy: {
|
|
239
|
+
...fromDashboardPolicy(requested.policy, current.policy),
|
|
240
|
+
},
|
|
241
|
+
inheritsModule: false,
|
|
242
|
+
expectedVersion: current.version,
|
|
243
|
+
updatedBy: input.actorUserId,
|
|
244
|
+
});
|
|
245
|
+
await audit.append({
|
|
246
|
+
guildId: input.guildId,
|
|
247
|
+
actorUserId: input.actorUserId,
|
|
248
|
+
action: "command.permission.updated",
|
|
249
|
+
source: "dashboard",
|
|
250
|
+
correlationId: input.correlationId,
|
|
251
|
+
moduleId: input.moduleId,
|
|
252
|
+
commandId: command.id,
|
|
253
|
+
afterState: { version: saved.version.toString() },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else if (module.defaultPermissions?.commands[command.id]) {
|
|
257
|
+
const saved = await policies.save({
|
|
258
|
+
guildId: input.guildId,
|
|
259
|
+
moduleId: input.moduleId,
|
|
260
|
+
commandId: command.id,
|
|
261
|
+
policy: { ...currentModule.policy },
|
|
262
|
+
inheritsModule: true,
|
|
263
|
+
expectedVersion: current.version,
|
|
264
|
+
updatedBy: input.actorUserId,
|
|
265
|
+
});
|
|
266
|
+
await audit.append({
|
|
267
|
+
guildId: input.guildId,
|
|
268
|
+
actorUserId: input.actorUserId,
|
|
269
|
+
action: "command.permission.inherited",
|
|
270
|
+
source: "dashboard",
|
|
271
|
+
correlationId: input.correlationId,
|
|
272
|
+
moduleId: input.moduleId,
|
|
273
|
+
commandId: command.id,
|
|
274
|
+
afterState: { version: saved.version.toString() },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
else if (current.version !== null) {
|
|
278
|
+
const deleted = await policies.deleteCommandPolicy(input.guildId, input.moduleId, command.id, current.version);
|
|
279
|
+
if (!deleted)
|
|
280
|
+
throw new OptimisticConcurrencyError("Command permission policy");
|
|
281
|
+
await audit.append({
|
|
282
|
+
guildId: input.guildId,
|
|
283
|
+
actorUserId: input.actorUserId,
|
|
284
|
+
action: "command.permission.cleared",
|
|
285
|
+
source: "dashboard",
|
|
286
|
+
correlationId: input.correlationId,
|
|
287
|
+
moduleId: input.moduleId,
|
|
288
|
+
commandId: command.id,
|
|
289
|
+
beforeState: { version: current.version.toString() },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return this.#getPermissionState(input.guildId, module);
|
|
295
|
+
}
|
|
296
|
+
async updateLoggingPolicy(input) {
|
|
297
|
+
if (!this.#modules.has("helyx.logging")) {
|
|
298
|
+
throw new BotControlNotFoundError("Logging is not installed");
|
|
299
|
+
}
|
|
300
|
+
if (!(await new InstallationRepository(this.database).isModuleEnabled(input.guildId, "helyx.logging"))) {
|
|
301
|
+
throw new BotControlValidationError("Enable Logging before changing activity events");
|
|
302
|
+
}
|
|
303
|
+
const source = this.#requireModule(input.moduleId);
|
|
304
|
+
const event = source.logging?.events.find(({ id }) => id === input.eventId);
|
|
305
|
+
if (!event) {
|
|
306
|
+
throw new BotControlNotFoundError("Logging event is not declared by this module");
|
|
307
|
+
}
|
|
308
|
+
const saved = await withTransaction(this.database, async (connection) => {
|
|
309
|
+
const policy = await new ModuleLoggingPolicyRepository(connection).save({
|
|
310
|
+
guildId: input.guildId,
|
|
311
|
+
sourceModuleId: input.moduleId,
|
|
312
|
+
eventId: input.eventId,
|
|
313
|
+
enabled: input.enabled,
|
|
314
|
+
expectedVersion: input.expectedVersion === 0 ? null : BigInt(input.expectedVersion),
|
|
315
|
+
updatedBy: input.actorUserId,
|
|
316
|
+
});
|
|
317
|
+
await new AuditRepository(connection).append({
|
|
318
|
+
guildId: input.guildId,
|
|
319
|
+
actorUserId: input.actorUserId,
|
|
320
|
+
action: "module.logging.updated",
|
|
321
|
+
source: "dashboard",
|
|
322
|
+
correlationId: input.correlationId,
|
|
323
|
+
moduleId: input.moduleId,
|
|
324
|
+
targetType: "module_log_event",
|
|
325
|
+
targetId: input.eventId,
|
|
326
|
+
afterState: {
|
|
327
|
+
enabled: input.enabled,
|
|
328
|
+
version: policy.version.toString(),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
return policy;
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
eventId: saved.eventId,
|
|
335
|
+
enabled: saved.enabled,
|
|
336
|
+
version: safeVersion(saved.version),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async executeDashboardAction(input) {
|
|
340
|
+
const module = this.#requireDashboardModule(input.moduleId);
|
|
341
|
+
const declared = module.manifest.dashboard?.actions?.find(({ id }) => id === input.actionId);
|
|
342
|
+
const action = module.dashboardActions?.find(({ id }) => id === input.actionId);
|
|
343
|
+
if (!declared || !action) {
|
|
344
|
+
throw new BotControlNotFoundError("Dashboard action is not declared by this module");
|
|
345
|
+
}
|
|
346
|
+
if (!(await new InstallationRepository(this.database).isModuleEnabled(input.guildId, input.moduleId))) {
|
|
347
|
+
throw new BotControlValidationError("Enable this module before running its dashboard actions");
|
|
348
|
+
}
|
|
349
|
+
return action.execute({
|
|
350
|
+
guildId: input.guildId,
|
|
351
|
+
actorUserId: input.actorUserId,
|
|
352
|
+
correlationId: input.correlationId,
|
|
353
|
+
services: this.#services,
|
|
354
|
+
sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
|
|
355
|
+
deleteChannelMessage: (channelId, messageId) => this.resources.deleteChannelMessage(channelId, messageId),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
#requireModule(moduleIdValue) {
|
|
359
|
+
const module = this.#modules.get(moduleIdValue);
|
|
360
|
+
if (!module)
|
|
361
|
+
throw new BotControlNotFoundError("Module is not installed");
|
|
362
|
+
return module;
|
|
363
|
+
}
|
|
364
|
+
#requireDashboardModule(moduleIdValue) {
|
|
365
|
+
const module = this.#requireModule(moduleIdValue);
|
|
366
|
+
if (!module.manifest.dashboard)
|
|
367
|
+
throw new BotControlNotFoundError("Module is not dashboard configurable");
|
|
368
|
+
return module;
|
|
369
|
+
}
|
|
370
|
+
async #getPermissionState(guildId, module) {
|
|
371
|
+
const commands = dashboardCommands(module);
|
|
372
|
+
const modulePolicy = await this.#permissions.getPolicy(guildId, module.manifest.id);
|
|
373
|
+
return {
|
|
374
|
+
version: safeOptionalVersion(modulePolicy.version),
|
|
375
|
+
module: toDashboardPolicy(modulePolicy.policy),
|
|
376
|
+
commands: await Promise.all(commands.map(async (command) => {
|
|
377
|
+
const value = await this.#permissions.getPolicy(guildId, module.manifest.id, command.id);
|
|
378
|
+
return {
|
|
379
|
+
commandId: command.id,
|
|
380
|
+
version: safeOptionalVersion(value.version),
|
|
381
|
+
enabled: !value.inherited ||
|
|
382
|
+
(value.version === null &&
|
|
383
|
+
modulePolicy.version === null &&
|
|
384
|
+
module.defaultPermissions?.commands[command.id] !== undefined),
|
|
385
|
+
policy: toDashboardPolicy(value.policy),
|
|
386
|
+
};
|
|
387
|
+
})),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async #validateDiscordSettings(guildId, module, value) {
|
|
391
|
+
const settings = module.manifest.dashboard?.settings ?? [];
|
|
392
|
+
if (!settings.some((setting) => setting.type === "discord-channel" || setting.type === "discord-role"))
|
|
393
|
+
return;
|
|
394
|
+
const resources = await this.resources.getGuildResources(guildId);
|
|
395
|
+
const channels = new Map(resources.channels.map((channel) => [channel.id, channel]));
|
|
396
|
+
const roles = new Set(resources.roles.map((role) => role.id));
|
|
397
|
+
for (const setting of settings) {
|
|
398
|
+
const configured = value[setting.key];
|
|
399
|
+
if (setting.type === "discord-channel") {
|
|
400
|
+
if (configured === "" || configured === undefined)
|
|
401
|
+
continue;
|
|
402
|
+
const channel = typeof configured === "string" ? channels.get(configured) : null;
|
|
403
|
+
if (!channel || !setting.channelTypes.includes(channel.type))
|
|
404
|
+
throw new BotControlValidationError(`${setting.label} must be an available supported Discord channel`);
|
|
405
|
+
}
|
|
406
|
+
if (setting.type === "discord-role") {
|
|
407
|
+
if (configured === "" || configured === undefined)
|
|
408
|
+
continue;
|
|
409
|
+
if (setting.multiple !== Array.isArray(configured))
|
|
410
|
+
throw new BotControlValidationError(`${setting.label} has an invalid Discord role selection`);
|
|
411
|
+
const configuredRoles = Array.isArray(configured)
|
|
412
|
+
? configured
|
|
413
|
+
: [configured];
|
|
414
|
+
if (configuredRoles.some((roleId) => typeof roleId !== "string" || !roles.has(roleId)))
|
|
415
|
+
throw new BotControlValidationError(`${setting.label} must contain available Discord roles`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async #validatePermissionRoles(guildId, state) {
|
|
420
|
+
const policies = [
|
|
421
|
+
state.module,
|
|
422
|
+
...state.commands.map(({ policy }) => policy),
|
|
423
|
+
];
|
|
424
|
+
for (const policy of policies) {
|
|
425
|
+
if (new Set(policy.allowedRoleIds).size !== policy.allowedRoleIds.length ||
|
|
426
|
+
new Set(policy.deniedRoleIds).size !== policy.deniedRoleIds.length) {
|
|
427
|
+
throw new BotControlValidationError("Permission settings contain duplicate Discord roles");
|
|
428
|
+
}
|
|
429
|
+
const denied = new Set(policy.deniedRoleIds);
|
|
430
|
+
if (policy.allowedRoleIds.some((roleId) => denied.has(roleId))) {
|
|
431
|
+
throw new BotControlValidationError("A Discord role cannot be both allowed and denied");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const requestedRoleIds = policies.flatMap((policy) => [
|
|
435
|
+
...policy.allowedRoleIds,
|
|
436
|
+
...policy.deniedRoleIds,
|
|
437
|
+
]);
|
|
438
|
+
if (requestedRoleIds.length === 0)
|
|
439
|
+
return;
|
|
440
|
+
const resources = await this.resources.getGuildResources(guildId);
|
|
441
|
+
const roles = new Set(resources.roles.map((role) => role.id));
|
|
442
|
+
if (requestedRoleIds.some((roleId) => !roles.has(roleId)))
|
|
443
|
+
throw new BotControlValidationError("Permission settings contain a Discord role that is no longer available");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
export async function createBotControlServer(input) {
|
|
447
|
+
const server = Fastify({
|
|
448
|
+
logger: input.logger ?? false,
|
|
449
|
+
bodyLimit: 64 * 1_024,
|
|
450
|
+
requestTimeout: 15_000,
|
|
451
|
+
});
|
|
452
|
+
const expectedSecret = digest(input.secret);
|
|
453
|
+
server.addHook("onRequest", async (request, reply) => {
|
|
454
|
+
if (request.url === "/health")
|
|
455
|
+
return;
|
|
456
|
+
const authorization = request.headers.authorization;
|
|
457
|
+
const supplied = authorization?.startsWith("Bearer ")
|
|
458
|
+
? authorization.slice("Bearer ".length)
|
|
459
|
+
: "";
|
|
460
|
+
if (!timingSafeEqual(expectedSecret, digest(supplied)))
|
|
461
|
+
return reply.code(401).send({ message: "Control authentication failed" });
|
|
462
|
+
});
|
|
463
|
+
server.get("/health", () => ({ status: "ok" }));
|
|
464
|
+
server.get("/v1/health", () => ({ status: "ok" }));
|
|
465
|
+
server.get("/v1/guilds/:guildId/state", async (request) => {
|
|
466
|
+
const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
|
|
467
|
+
return input.service.getGuildState(guildId);
|
|
468
|
+
});
|
|
469
|
+
server.put("/v1/guilds/:guildId/modules/:moduleId/status", async (request) => {
|
|
470
|
+
const params = z
|
|
471
|
+
.object({ guildId: snowflake, moduleId })
|
|
472
|
+
.parse(request.params);
|
|
473
|
+
const body = mutationContext
|
|
474
|
+
.extend({ enabled: z.boolean() })
|
|
475
|
+
.parse(request.body);
|
|
476
|
+
return input.service.setModuleStatus({ ...params, ...body });
|
|
477
|
+
});
|
|
478
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/actions/:actionId", async (request) => {
|
|
479
|
+
const params = z
|
|
480
|
+
.object({
|
|
481
|
+
guildId: snowflake,
|
|
482
|
+
moduleId,
|
|
483
|
+
actionId: z.string().regex(/^[a-z][a-z0-9_-]*$/u),
|
|
484
|
+
})
|
|
485
|
+
.parse(request.params);
|
|
486
|
+
const body = mutationContext.parse(request.body);
|
|
487
|
+
return input.service.executeDashboardAction({ ...params, ...body });
|
|
488
|
+
});
|
|
489
|
+
server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {
|
|
490
|
+
const params = z
|
|
491
|
+
.object({ guildId: snowflake, moduleId })
|
|
492
|
+
.parse(request.params);
|
|
493
|
+
const body = mutationContext
|
|
494
|
+
.extend({
|
|
495
|
+
value: configurationValue,
|
|
496
|
+
expectedVersion: z.number().int().positive().nullable(),
|
|
497
|
+
})
|
|
498
|
+
.parse(request.body);
|
|
499
|
+
return input.service.updateConfiguration({ ...params, ...body });
|
|
500
|
+
});
|
|
501
|
+
server.put("/v1/guilds/:guildId/modules/:moduleId/permissions", async (request) => {
|
|
502
|
+
const params = z
|
|
503
|
+
.object({ guildId: snowflake, moduleId })
|
|
504
|
+
.parse(request.params);
|
|
505
|
+
const body = mutationContext
|
|
506
|
+
.extend({ state: permissionState })
|
|
507
|
+
.parse(request.body);
|
|
508
|
+
return input.service.updatePermissions({ ...params, ...body });
|
|
509
|
+
});
|
|
510
|
+
server.put("/v1/guilds/:guildId/modules/:moduleId/logging/:eventId", async (request) => {
|
|
511
|
+
const params = z
|
|
512
|
+
.object({
|
|
513
|
+
guildId: snowflake,
|
|
514
|
+
moduleId,
|
|
515
|
+
eventId: z.string().regex(/^[a-z][a-z0-9_-]*$/u),
|
|
516
|
+
})
|
|
517
|
+
.parse(request.params);
|
|
518
|
+
const body = mutationContext
|
|
519
|
+
.extend({
|
|
520
|
+
enabled: z.boolean(),
|
|
521
|
+
expectedVersion: z.number().int().min(0),
|
|
522
|
+
})
|
|
523
|
+
.parse(request.body);
|
|
524
|
+
return input.service.updateLoggingPolicy({ ...params, ...body });
|
|
525
|
+
});
|
|
526
|
+
server.setErrorHandler(async (error, request, reply) => {
|
|
527
|
+
if (error instanceof ZodError)
|
|
528
|
+
return reply
|
|
529
|
+
.code(400)
|
|
530
|
+
.send({ message: "The control request was invalid" });
|
|
531
|
+
if (error instanceof BotControlValidationError)
|
|
532
|
+
return reply.code(400).send({ message: error.message });
|
|
533
|
+
if (error instanceof DashboardActionValidationError)
|
|
534
|
+
return reply.code(400).send({ message: error.message });
|
|
535
|
+
if (error instanceof ModuleStateValidationError)
|
|
536
|
+
return reply.code(400).send({ message: error.message });
|
|
537
|
+
if (error instanceof BotControlNotFoundError)
|
|
538
|
+
return reply.code(404).send({ message: error.message });
|
|
539
|
+
if (error instanceof OptimisticConcurrencyError)
|
|
540
|
+
return reply.code(409).send({
|
|
541
|
+
message: "Configuration changed in another session. Reload and try again.",
|
|
542
|
+
});
|
|
543
|
+
const safeError = error instanceof Error
|
|
544
|
+
? { name: error.name, message: error.message }
|
|
545
|
+
: { name: "Error", message: "Unknown control failure" };
|
|
546
|
+
request.log.error({ error: safeError }, "Private bot control request failed");
|
|
547
|
+
return reply.code(500).send({ message: "The control request failed" });
|
|
548
|
+
});
|
|
549
|
+
return server;
|
|
550
|
+
}
|
|
551
|
+
class BotControlValidationError extends Error {
|
|
552
|
+
}
|
|
553
|
+
class BotControlNotFoundError extends Error {
|
|
554
|
+
constructor(message = "Discord server is not available to Helyx") {
|
|
555
|
+
super(message);
|
|
556
|
+
this.name = "BotControlNotFoundError";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function digest(value) {
|
|
560
|
+
return createHash("sha256").update(value).digest();
|
|
561
|
+
}
|
|
562
|
+
function isUnknownGuildError(error) {
|
|
563
|
+
return (typeof error === "object" &&
|
|
564
|
+
error !== null &&
|
|
565
|
+
"code" in error &&
|
|
566
|
+
error.code === 10_004);
|
|
567
|
+
}
|
|
568
|
+
function mapDashboardModule(module) {
|
|
569
|
+
const dashboard = module.manifest.dashboard;
|
|
570
|
+
return {
|
|
571
|
+
id: module.manifest.id,
|
|
572
|
+
name: module.manifest.name,
|
|
573
|
+
version: module.manifest.version,
|
|
574
|
+
summary: module.manifest.description,
|
|
575
|
+
category: dashboard.category[0].toUpperCase() + dashboard.category.slice(1),
|
|
576
|
+
icon: dashboard.icon,
|
|
577
|
+
availability: "available",
|
|
578
|
+
requiredPermissions: [...module.manifest.permissions],
|
|
579
|
+
settings: dashboard.settings,
|
|
580
|
+
commands: dashboardCommands(module),
|
|
581
|
+
logEvents: (module.logging?.events ?? []).map((event) => ({ ...event })),
|
|
582
|
+
actions: (dashboard.actions ?? []).map((action) => ({ ...action })),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function dashboardCommands(module) {
|
|
586
|
+
return (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
|
|
587
|
+
? command.subcommands.map((subcommand) => {
|
|
588
|
+
const id = `${command.name}.${subcommand.name}`;
|
|
589
|
+
return {
|
|
590
|
+
id,
|
|
591
|
+
name: `/${command.name} ${subcommand.name}`,
|
|
592
|
+
description: subcommand.description,
|
|
593
|
+
defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[id] ??
|
|
594
|
+
module.defaultPermissions?.module ??
|
|
595
|
+
emptyPolicy()).preset,
|
|
596
|
+
};
|
|
597
|
+
})
|
|
598
|
+
: [
|
|
599
|
+
{
|
|
600
|
+
id: command.name,
|
|
601
|
+
name: `/${command.name}`,
|
|
602
|
+
description: command.description,
|
|
603
|
+
defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[command.name] ??
|
|
604
|
+
module.defaultPermissions?.module ??
|
|
605
|
+
emptyPolicy()).preset,
|
|
606
|
+
},
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
function toDashboardPolicy(policy) {
|
|
610
|
+
const preset = policy.everyone
|
|
611
|
+
? "everyone"
|
|
612
|
+
: policy.allowedRoleIds.length > 0 &&
|
|
613
|
+
policy.requiredPermissions.length === 0
|
|
614
|
+
? "custom"
|
|
615
|
+
: policy.requiredPermissions.some((permission) => ["ADMINISTRATOR", "MANAGE_GUILD"].includes(permission))
|
|
616
|
+
? "administrators"
|
|
617
|
+
: policy.requiredPermissions.length > 0
|
|
618
|
+
? "moderators"
|
|
619
|
+
: "custom";
|
|
620
|
+
return {
|
|
621
|
+
preset,
|
|
622
|
+
allowedRoleIds: [...policy.allowedRoleIds],
|
|
623
|
+
deniedRoleIds: [...policy.deniedRoleIds],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function fromDashboardPolicy(input, current) {
|
|
627
|
+
return {
|
|
628
|
+
everyone: input.preset === "everyone",
|
|
629
|
+
requiredPermissions: input.preset === "administrators"
|
|
630
|
+
? ["MANAGE_GUILD"]
|
|
631
|
+
: input.preset === "moderators"
|
|
632
|
+
? ["MANAGE_MESSAGES"]
|
|
633
|
+
: [],
|
|
634
|
+
allowedRoleIds: input.preset === "custom" ? input.allowedRoleIds : [],
|
|
635
|
+
deniedRoleIds: input.deniedRoleIds,
|
|
636
|
+
allowedUserIds: [...current.allowedUserIds],
|
|
637
|
+
deniedUserIds: [...current.deniedUserIds],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function emptyPolicy() {
|
|
641
|
+
return {
|
|
642
|
+
everyone: false,
|
|
643
|
+
requiredPermissions: [],
|
|
644
|
+
allowedRoleIds: [],
|
|
645
|
+
deniedRoleIds: [],
|
|
646
|
+
allowedUserIds: [],
|
|
647
|
+
deniedUserIds: [],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function safeVersion(version) {
|
|
651
|
+
const value = Number(version);
|
|
652
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
653
|
+
throw new Error("Configuration version exceeded the supported range");
|
|
654
|
+
return value;
|
|
655
|
+
}
|
|
656
|
+
function safeOptionalVersion(version) {
|
|
657
|
+
return version === null ? 0 : safeVersion(version);
|
|
658
|
+
}
|
|
659
|
+
function assertVersion(actual, requested) {
|
|
660
|
+
if (safeOptionalVersion(actual) !== requested)
|
|
661
|
+
throw new OptimisticConcurrencyError("Permission policy");
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=control-server.js.map
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ModuleDefinition, ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
|
|
3
|
+
import { type InteractionLogger } from "./interactions.js";
|
|
4
|
+
export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): void;
|
|
5
|
+
export declare function reactionEmojiKey(reaction: MessageReaction | PartialMessageReaction): string;
|
|
6
|
+
export declare function reactionCacheKey(emojiIdentifier: string): string;
|
|
7
|
+
//# sourceMappingURL=events.d.ts.map
|