@helyx/bot 0.4.2 → 0.4.4

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +13 -0
  3. package/dist/cloud-connector.d.ts +7 -0
  4. package/dist/cloud-connector.js +118 -1
  5. package/dist/cloud-control-dispatcher.js +84 -0
  6. package/dist/control-server.d.ts +187 -5
  7. package/dist/control-server.js +673 -6
  8. package/dist/developer-operations-module.js +8 -0
  9. package/dist/discord-owned-message-service.d.ts +39 -0
  10. package/dist/discord-owned-message-service.js +138 -0
  11. package/dist/events.d.ts +70 -1
  12. package/dist/events.js +557 -5
  13. package/dist/index.js +61 -13
  14. package/dist/interactions.d.ts +3 -2
  15. package/dist/interactions.js +66 -31
  16. package/dist/module-logging.d.ts +1 -0
  17. package/dist/module-logging.js +19 -1
  18. package/dist/modules.d.ts +9 -0
  19. package/dist/modules.js +45 -0
  20. package/dist/resource-authorization.d.ts +7 -0
  21. package/dist/resource-authorization.js +12 -0
  22. package/dist/scheduled-task-runner.d.ts +130 -0
  23. package/dist/scheduled-task-runner.js +417 -0
  24. package/dist/shard-cloud-proxy.d.ts +4 -0
  25. package/dist/shard-cloud-proxy.js +34 -0
  26. package/dist/shard-contracts.d.ts +574 -23
  27. package/dist/shard-contracts.js +346 -0
  28. package/dist/shard-coordinator.d.ts +5 -3
  29. package/dist/shard-coordinator.js +85 -2
  30. package/dist/shard-router.d.ts +10 -0
  31. package/dist/shard-router.js +58 -0
  32. package/dist/shard-supervisor.d.ts +2 -1
  33. package/dist/shard-supervisor.js +187 -5
  34. package/dist/shard-worker.d.ts +9 -3
  35. package/dist/shard-worker.js +97 -16
  36. package/dist/support-module.d.ts +9 -0
  37. package/dist/support-module.js +172 -0
  38. package/package.json +9 -9
@@ -9,18 +9,22 @@ import { monitorEventLoopDelay } from "node:perf_hooks";
9
9
  import pino from "pino";
10
10
  import { createCloudManagementModule } from "./cloud-management-module.js";
11
11
  import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
12
- import { installModuleEvents } from "./events.js";
12
+ import { DiscordMemberRoleService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
13
13
  import { installGuildTracking } from "./guilds.js";
14
14
  import { installInteractions, } from "./interactions.js";
15
15
  import { createManagementModule } from "./management-module.js";
16
16
  import { DiscordModuleLoggingService } from "./module-logging.js";
17
17
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
18
- import { loadConfiguredModulePackages } from "./modules.js";
18
+ import { loadConfiguredModulePackages, platformServiceOptions, secureRandomSelectionWorkerKeyName, } from "./modules.js";
19
19
  import { createPermissionsModule } from "./permissions-module.js";
20
20
  import { WorkerCloudConnectorProxy } from "./shard-cloud-proxy.js";
21
+ import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
22
+ import { CoreResourceAuthorizationService } from "./resource-authorization.js";
21
23
  import { createDeveloperOperationsModule, WorkerShardHealthProvider, } from "./developer-operations-module.js";
22
24
  import { assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, guildIdToShardId, SHARD_LEASE_HEARTBEAT_TIMEOUT_MS, shardIdentitySchema, supervisorMessageSchema, } from "./shard-contracts.js";
23
25
  import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
26
+ import { createSupportModule } from "./support-module.js";
27
+ import { createLocalScheduledTaskRoute, } from "./scheduled-task-runner.js";
24
28
  const HEALTH_INTERVAL_MS = 5_000;
25
29
  export const WORKER_ORPHAN_TERMINATION_BOUND_MS = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS + 5_000;
26
30
  export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS) {
@@ -109,10 +113,8 @@ export async function startShardWorker(processEnvironment = process.env, identit
109
113
  const guildResources = new DiscordGuildResourceDirectory(client);
110
114
  const permissionsModule = createPermissionsModule(featureModules);
111
115
  const managementModule = createManagementModule(featureModules, guildResources);
112
- cloudProxy = environment.HELYX_CLOUD_CONNECT_ENABLED
113
- ? new WorkerCloudConnectorProxy()
114
- : undefined;
115
- const cloudManagementModule = cloudProxy
116
+ cloudProxy = new WorkerCloudConnectorProxy();
117
+ const cloudManagementModule = environment.HELYX_CLOUD_CONNECT_ENABLED
116
118
  ? createCloudManagementModule({
117
119
  connector: cloudProxy,
118
120
  client,
@@ -120,9 +122,11 @@ export async function startShardWorker(processEnvironment = process.env, identit
120
122
  periodicOwnerRefresh: false,
121
123
  })
122
124
  : undefined;
125
+ const supportModule = createSupportModule(cloudProxy);
123
126
  const publicModules = [
124
127
  managementModule,
125
128
  permissionsModule,
129
+ supportModule,
126
130
  ...(cloudManagementModule ? [cloudManagementModule] : []),
127
131
  ...featureModules,
128
132
  ];
@@ -140,6 +144,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
140
144
  coreFunctions: [
141
145
  managementModule,
142
146
  permissionsModule,
147
+ supportModule,
143
148
  ...(cloudManagementModule ? [cloudManagementModule] : []),
144
149
  ].map(({ manifest }) => manifest),
145
150
  modules: featureModules.map(({ manifest }) => manifest),
@@ -151,14 +156,24 @@ export async function startShardWorker(processEnvironment = process.env, identit
151
156
  ...publicModules,
152
157
  ...(operationsModule ? [operationsModule] : []),
153
158
  ];
154
- const platformServices = new Map(createPlatformServices(database, publicModules));
159
+ const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
160
+ platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
161
+ platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
162
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
163
+ platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
155
164
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging"))
156
165
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, logger));
157
166
  const serviceAccess = new ServiceRegistry(platformServices);
167
+ const scheduledTaskRoute = createLocalScheduledTaskRoute({
168
+ modules: publicModules,
169
+ services: serviceAccess,
170
+ logger,
171
+ });
158
172
  runtime = createRuntime({ modules, logger, services: platformServices });
159
173
  const enabledCoreModuleIds = new Set([
160
174
  managementModule.manifest.id,
161
175
  permissionsModule.manifest.id,
176
+ supportModule.manifest.id,
162
177
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
163
178
  ...(operationsModule ? [operationsModule.manifest.id] : []),
164
179
  ]);
@@ -177,9 +192,16 @@ export async function startShardWorker(processEnvironment = process.env, identit
177
192
  ...(operationsModule
178
193
  ? { operationsModuleId: operationsModule.manifest.id }
179
194
  : {}),
195
+ supportModuleId: supportModule.manifest.id,
180
196
  }), logger, serviceAccess);
181
197
  moduleEvents = installModuleEvents(client, publicModules, logger, serviceAccess);
182
- const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess);
198
+ const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess, undefined, environment.DISCORD_INTERNAL_GUILD_ID &&
199
+ environment.HELYX_SUPPORT_STAFF_CHANNEL_ID
200
+ ? {
201
+ guildId: environment.DISCORD_INTERNAL_GUILD_ID,
202
+ channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
203
+ }
204
+ : undefined);
183
205
  process.on("message", (raw) => {
184
206
  const parsed = supervisorMessageSchema.safeParse(raw);
185
207
  if (!parsed.success)
@@ -200,6 +222,13 @@ export async function startShardWorker(processEnvironment = process.env, identit
200
222
  else if (parsed.data.type === "helyx.operations.health.response") {
201
223
  shardHealthProvider?.accept(parsed.data);
202
224
  }
225
+ else if (parsed.data.type === "helyx.scheduled.request") {
226
+ void executeShardScheduledTaskRequest(identity, parsed.data, scheduledTaskRoute).then((response) => {
227
+ assertShardMessageSize(response);
228
+ if (process.connected)
229
+ process.send?.(response);
230
+ });
231
+ }
203
232
  else if (parsed.data.type === "helyx.shutdown") {
204
233
  void shutdown(parsed.data.reason).finally(() => {
205
234
  if (process.connected)
@@ -236,7 +265,8 @@ export async function startShardWorker(processEnvironment = process.env, identit
236
265
  client.on(Events.Error, (error) => {
237
266
  logger.error({ event: "discord.error", error: safeError(error) }, "Discord shard error");
238
267
  });
239
- await cloudProxy?.start();
268
+ if (environment.HELYX_CLOUD_CONNECT_ENABLED)
269
+ await cloudProxy.start();
240
270
  await runtime.start();
241
271
  runtimeStarted = true;
242
272
  await client.login(environment.DISCORD_TOKEN);
@@ -253,6 +283,50 @@ export async function startShardWorker(processEnvironment = process.env, identit
253
283
  throw error;
254
284
  }
255
285
  }
286
+ export async function executeShardScheduledTaskRequest(identity, request, route, now = Date.now()) {
287
+ const defer = (safeCode) => ({
288
+ version: 1,
289
+ type: "helyx.scheduled.response",
290
+ requestId: request.requestId,
291
+ result: { outcome: "deferred", delayMs: 1_000, safeCode },
292
+ });
293
+ if (!sameShardIdentity(request.identity, identity))
294
+ return defer("stale_owner");
295
+ if (new Date(request.deadlineAt).getTime() <= now)
296
+ return defer("deadline_expired");
297
+ if (guildIdToShardId(request.task.guildId, identity.shardCount) !==
298
+ identity.shardId)
299
+ return defer("wrong_shard");
300
+ const controller = new AbortController();
301
+ const remaining = Math.max(1, new Date(request.deadlineAt).getTime() - now);
302
+ const timer = setTimeout(() => controller.abort(), remaining);
303
+ timer.unref();
304
+ try {
305
+ return {
306
+ version: 1,
307
+ type: "helyx.scheduled.response",
308
+ requestId: request.requestId,
309
+ result: await route(request.task, controller.signal),
310
+ };
311
+ }
312
+ catch {
313
+ return {
314
+ version: 1,
315
+ type: "helyx.scheduled.response",
316
+ requestId: request.requestId,
317
+ result: {
318
+ outcome: "retry",
319
+ delayMs: 1_000,
320
+ safeCode: controller.signal.aborted
321
+ ? "deadline_expired"
322
+ : "handler_failed",
323
+ },
324
+ };
325
+ }
326
+ finally {
327
+ clearTimeout(timer);
328
+ }
329
+ }
256
330
  function sameShardIdentity(left, right) {
257
331
  return (left.generation === right.generation &&
258
332
  left.deploymentOrder === right.deploymentOrder &&
@@ -282,7 +356,8 @@ export function parseShardWorkerEnvironment(processEnvironment, identity) {
282
356
  ...(cloudEnabled
283
357
  ? {
284
358
  HELYX_BOT_CONTROL_SECRET: Buffer.alloc(32).toString("base64"),
285
- HELYX_DATA_ENCRYPTION_KEY: Buffer.alloc(32).toString("base64"),
359
+ HELYX_DATA_ENCRYPTION_KEY: processEnvironment[secureRandomSelectionWorkerKeyName] ??
360
+ Buffer.alloc(32).toString("base64"),
286
361
  }
287
362
  : {}),
288
363
  ...(identity.scope === "coordinated"
@@ -293,7 +368,14 @@ export function parseShardWorkerEnvironment(processEnvironment, identity) {
293
368
  environment.HELYX_SHARD_MODE !== identity.mode ||
294
369
  environment.HELYX_SHARD_SCOPE !== identity.scope)
295
370
  throw new Error("Shard worker launch contract conflicts with configuration");
296
- return environment;
371
+ const internalSelectionKey = processEnvironment[secureRandomSelectionWorkerKeyName];
372
+ return {
373
+ ...environment,
374
+ HELYX_DATA_ENCRYPTION_KEY: undefined,
375
+ ...(internalSelectionKey
376
+ ? { HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY: internalSelectionKey }
377
+ : {}),
378
+ };
297
379
  }
298
380
  export function shardWorkerCommandRegistration(input) {
299
381
  return {
@@ -302,11 +384,10 @@ export function shardWorkerCommandRegistration(input) {
302
384
  : {}),
303
385
  prepare: input.prepare,
304
386
  synchronise: false,
305
- ...(input.operationsModuleId
306
- ? {
307
- selfAuthorizingGuildModuleIds: new Set([input.operationsModuleId]),
308
- }
309
- : {}),
387
+ selfAuthorizingGuildModuleIds: new Set([
388
+ input.supportModuleId,
389
+ ...(input.operationsModuleId ? [input.operationsModuleId] : []),
390
+ ]),
310
391
  };
311
392
  }
312
393
  async function handleControl(identity, service, request) {
@@ -0,0 +1,9 @@
1
+ import type { SupportBugCreatePayload, SupportBugCreateResponsePayload } from "@helyx/control-protocol";
2
+ import { type ModuleDefinition } from "@helyx/sdk";
3
+ export interface SupportBugTransport {
4
+ submitBug(input: SupportBugCreatePayload): Promise<Extract<SupportBugCreateResponsePayload, {
5
+ ok: true;
6
+ }>["result"]>;
7
+ }
8
+ export declare function createSupportModule(transport: SupportBugTransport): ModuleDefinition;
9
+ //# sourceMappingURL=support-module.d.ts.map
@@ -0,0 +1,172 @@
1
+ import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ const MODAL_ID = "helyx.support.bug";
4
+ const MODULE_FIELD_ID = "helyx.support.bug.module";
5
+ const SUMMARY_FIELD_ID = "helyx.support.bug.summary";
6
+ const HAPPENED_FIELD_ID = "helyx.support.bug.happened";
7
+ const DETAIL_FIELD_ID = "helyx.support.bug.detail";
8
+ const SUPPORT_URL = "https://app.helyx.gg/support";
9
+ const MODULE_ID = "helyx.support";
10
+ const HOUR_SECONDS = 60 * 60;
11
+ export function createSupportModule(transport) {
12
+ return defineModule({
13
+ manifest: {
14
+ manifestVersion: "0.1",
15
+ releaseStatus: "live",
16
+ id: MODULE_ID,
17
+ name: "Helyx Support",
18
+ version: "0.1.0",
19
+ description: "Provides the always-available Helyx bug report command.",
20
+ entry: "./dist/support-module.js",
21
+ framework: "^0.1.0",
22
+ dependencies: [],
23
+ permissions: [],
24
+ contributions: { commands: ["bug"], events: [] },
25
+ },
26
+ interactions: {
27
+ commands: [
28
+ {
29
+ name: "bug",
30
+ description: "Report a Helyx bug to the support team",
31
+ registration: "global",
32
+ contexts: ["guild"],
33
+ integrationTypes: ["guild_install"],
34
+ async execute(context) {
35
+ await context.showModal({
36
+ customId: MODAL_ID,
37
+ title: "Report a Helyx bug",
38
+ fields: [
39
+ {
40
+ customId: MODULE_FIELD_ID,
41
+ label: "Module or area",
42
+ style: "short",
43
+ required: true,
44
+ minLength: 1,
45
+ maxLength: 100,
46
+ },
47
+ {
48
+ customId: SUMMARY_FIELD_ID,
49
+ label: "Summary",
50
+ style: "short",
51
+ required: true,
52
+ minLength: 10,
53
+ maxLength: 200,
54
+ },
55
+ {
56
+ customId: HAPPENED_FIELD_ID,
57
+ label: "What happened?",
58
+ style: "paragraph",
59
+ required: true,
60
+ minLength: 10,
61
+ maxLength: 1_000,
62
+ },
63
+ {
64
+ customId: DETAIL_FIELD_ID,
65
+ label: "Steps to reproduce or extra detail",
66
+ style: "paragraph",
67
+ required: false,
68
+ maxLength: 1_000,
69
+ },
70
+ ],
71
+ });
72
+ },
73
+ },
74
+ ],
75
+ modals: [
76
+ {
77
+ customId: MODAL_ID,
78
+ async execute(context) {
79
+ if (!context.serverId) {
80
+ await context.reply(notSavedResponse());
81
+ return;
82
+ }
83
+ const limits = context.services.get(HELYX_SERVICE_NAMES.rateLimits);
84
+ const userLimit = await limits.consume({
85
+ scope: "user",
86
+ subjectId: context.userId,
87
+ action: `${MODULE_ID}:bug:create:user`,
88
+ limit: 5,
89
+ windowSeconds: HOUR_SECONDS,
90
+ });
91
+ const guildLimit = userLimit.allowed
92
+ ? await limits.consume({
93
+ scope: "guild",
94
+ subjectId: context.serverId,
95
+ action: `${MODULE_ID}:bug:create:guild`,
96
+ limit: 20,
97
+ windowSeconds: HOUR_SECONDS,
98
+ })
99
+ : undefined;
100
+ const denied = !userLimit.allowed
101
+ ? userLimit
102
+ : guildLimit && !guildLimit.allowed
103
+ ? guildLimit
104
+ : undefined;
105
+ if (denied) {
106
+ await context.reply({
107
+ ephemeral: true,
108
+ componentsV2: {
109
+ text: [
110
+ {
111
+ content: `Bug reporting is temporarily rate limited. Try again <t:${Math.ceil(denied.resetsAt.getTime() / 1_000)}:R>.`,
112
+ },
113
+ ],
114
+ },
115
+ });
116
+ return;
117
+ }
118
+ await context.deferReply?.(true);
119
+ const detail = context.getField(DETAIL_FIELD_ID).trim();
120
+ try {
121
+ const result = await transport.submitBug({
122
+ operation: "support.bug.create.v1",
123
+ reporterDiscordUserId: context.userId,
124
+ guildId: context.serverId,
125
+ moduleOrArea: context.getField(MODULE_FIELD_ID).trim(),
126
+ summary: context.getField(SUMMARY_FIELD_ID).trim(),
127
+ whatHappened: context.getField(HAPPENED_FIELD_ID).trim(),
128
+ ...(detail ? { extraDetail: detail } : {}),
129
+ interactionId: context.interactionId,
130
+ correlationId: randomUUID(),
131
+ });
132
+ await context.reply(successResponse(result));
133
+ }
134
+ catch {
135
+ await context.reply(notSavedResponse());
136
+ }
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ start: () => Promise.resolve(),
142
+ stop: () => Promise.resolve(),
143
+ });
144
+ }
145
+ function successResponse(result) {
146
+ const content = result.claimUrl
147
+ ? `Bug report **${result.reference}** has been received from this self-hosted deployment. Use this one-time link to claim it, then track the report and reply through Helyx Support: ${result.claimUrl}`
148
+ : `Bug report **${result.reference}** has been received. You can track the report and reply through Helyx Support: ${SUPPORT_URL}`;
149
+ return {
150
+ ephemeral: true,
151
+ componentsV2: {
152
+ text: [
153
+ {
154
+ content,
155
+ },
156
+ ],
157
+ },
158
+ };
159
+ }
160
+ function notSavedResponse() {
161
+ return {
162
+ ephemeral: true,
163
+ componentsV2: {
164
+ text: [
165
+ {
166
+ content: `The bug report was not saved. Please use the Helyx dashboard: ${SUPPORT_URL}`,
167
+ },
168
+ ],
169
+ },
170
+ };
171
+ }
172
+ //# sourceMappingURL=support-module.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -48,14 +48,14 @@
48
48
  "start": "node --env-file-if-exists=../../.env dist/index.js"
49
49
  },
50
50
  "dependencies": {
51
- "@helyx/config": "0.4.1",
52
- "@helyx/control-protocol": "0.1.2",
53
- "@helyx/core": "0.1.7",
54
- "@helyx/database": "0.4.0",
55
- "@helyx/framework": "0.1.7",
56
- "@helyx/module-manifest": "0.4.0",
57
- "@helyx/platform": "0.2.3",
58
- "@helyx/sdk": "0.3.2",
51
+ "@helyx/config": "0.4.2",
52
+ "@helyx/control-protocol": "0.1.4",
53
+ "@helyx/core": "0.1.8",
54
+ "@helyx/database": "0.4.2",
55
+ "@helyx/framework": "0.1.8",
56
+ "@helyx/module-manifest": "0.4.1",
57
+ "@helyx/platform": "0.2.5",
58
+ "@helyx/sdk": "0.3.3",
59
59
  "discord.js": "14.27.0",
60
60
  "fastify": "5.10.0",
61
61
  "pino": "10.3.1",