@helyx/bot 0.4.2 → 0.4.5

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 +35 -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
@@ -1,10 +1,11 @@
1
1
  import { AuditRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
2
2
  import { DashboardActionValidationError, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
- import { ChannelType } from "discord.js";
3
+ import { canUseCommand } from "@helyx/core";
4
+ import { ChannelType, PermissionFlagsBits } from "discord.js";
4
5
  import Fastify, {} from "fastify";
5
6
  import { createHash, timingSafeEqual } from "node:crypto";
6
7
  import { z, ZodError } from "zod";
7
- import { deleteChannelMessage as deleteDiscordChannelMessage, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
8
+ import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteChannelMessage as deleteDiscordChannelMessage, editChannelMessage as editDiscordChannelMessage, normalizeDiscordPermission, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
8
9
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
9
10
  const snowflake = z.string().regex(/^\d{17,20}$/u);
10
11
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
@@ -41,6 +42,55 @@ const mutationContext = z.object({
41
42
  actorUserId: snowflake,
42
43
  correlationId: z.uuid(),
43
44
  });
45
+ const resourceId = z.string().trim().min(1).max(100);
46
+ const resourceCursor = z.string().min(1).max(2_048);
47
+ const resourceActionId = z.string().regex(/^[a-z][a-z0-9_-]*$/u);
48
+ const managedResourceFilters = z
49
+ .record(z.string().regex(/^[a-z][a-zA-Z0-9_]*$/u), z.string().trim().min(1).max(100))
50
+ .refine((value) => Object.keys(value).length <= 5);
51
+ const managedResourceValue = z.custom((value) => isBoundedManagedResourceValue(value), "Managed resource value exceeds the transport limits");
52
+ const managedResourceObject = z
53
+ .record(z.string().min(1).max(100), managedResourceValue)
54
+ .refine(hasBoundedManagedResourceBytes);
55
+ const managedResourceSummarySchema = z
56
+ .object({
57
+ id: resourceId,
58
+ revision: z.number().int().min(0),
59
+ status: z.string().trim().min(1).max(100),
60
+ title: z.string().trim().min(1).max(200),
61
+ description: z.string().max(500).optional(),
62
+ attributes: managedResourceObject.optional(),
63
+ updatedAt: z.iso.datetime({ offset: true }),
64
+ })
65
+ .strict();
66
+ const managedResourceDetailSchema = managedResourceSummarySchema
67
+ .extend({ value: managedResourceObject })
68
+ .strict()
69
+ .refine(hasBoundedManagedResourceBytes);
70
+ const managedResourcePageSchema = z
71
+ .object({
72
+ items: z.array(managedResourceSummarySchema).max(50),
73
+ nextCursor: resourceCursor.optional(),
74
+ })
75
+ .strict()
76
+ .refine(hasBoundedManagedResourceBytes);
77
+ const managedResourceRelationPageSchema = z
78
+ .object({
79
+ items: z
80
+ .array(z
81
+ .object({
82
+ id: resourceId,
83
+ value: managedResourceObject,
84
+ })
85
+ .strict())
86
+ .max(50),
87
+ nextCursor: resourceCursor.optional(),
88
+ })
89
+ .strict()
90
+ .refine(hasBoundedManagedResourceBytes);
91
+ const managedResourceDeleteResultSchema = z
92
+ .object({ deleted: z.boolean() })
93
+ .strict();
44
94
  export class DiscordGuildResourceDirectory {
45
95
  client;
46
96
  constructor(client) {
@@ -87,6 +137,55 @@ export class DiscordGuildResourceDirectory {
87
137
  deleteChannelMessage(channelId, messageId) {
88
138
  return deleteDiscordChannelMessage(this.client, channelId, messageId);
89
139
  }
140
+ editChannelMessage(channelId, messageId, response) {
141
+ return editDiscordChannelMessage(this.client, channelId, messageId, response);
142
+ }
143
+ addChannelMessageReaction(channelId, messageId, emojiIdentifier) {
144
+ return addDiscordChannelMessageReaction(this.client, channelId, messageId, emojiIdentifier);
145
+ }
146
+ async resolveActor(guildId, actorUserId) {
147
+ try {
148
+ const guild = await this.client.guilds.fetch(guildId);
149
+ const member = await guild.members.fetch(actorUserId);
150
+ return {
151
+ userId: member.id,
152
+ roleIds: [...member.roles.cache.keys()],
153
+ permissions: member.permissions
154
+ .toArray()
155
+ .map(normalizeDiscordPermission),
156
+ isServerOwner: guild.ownerId === member.id,
157
+ isAdministrator: member.permissions.has(PermissionFlagsBits.Administrator),
158
+ };
159
+ }
160
+ catch (error) {
161
+ if (isUnknownGuildError(error))
162
+ throw new BotControlNotFoundError();
163
+ if (isUnknownMemberError(error))
164
+ throw new BotControlForbiddenError();
165
+ throw error;
166
+ }
167
+ }
168
+ async sendPrivateSupportNotification(guildId, channelId, response, notificationId) {
169
+ let guild;
170
+ try {
171
+ guild = await this.client.guilds.fetch(guildId);
172
+ }
173
+ catch (error) {
174
+ if (isUnknownGuildError(error))
175
+ throw new BotControlNotFoundError();
176
+ throw error;
177
+ }
178
+ const channel = await guild.channels.fetch(channelId);
179
+ if (!channel ||
180
+ (channel.type !== ChannelType.GuildText &&
181
+ channel.type !== ChannelType.GuildAnnouncement) ||
182
+ !channel.isSendable() ||
183
+ channel
184
+ .permissionsFor(guild.roles.everyone)
185
+ ?.has(PermissionFlagsBits.ViewChannel))
186
+ throw new BotControlNotFoundError("The support notification channel is missing or not private");
187
+ await sendDiscordMessageToChannel(this.client, channel.id, response, notificationId);
188
+ }
90
189
  }
91
190
  export class BotControlTransportError extends Error {
92
191
  status;
@@ -99,6 +198,7 @@ export class BotControlTransportError extends Error {
99
198
  export class BotControlService {
100
199
  database;
101
200
  resources;
201
+ supportNotifications;
102
202
  #modules;
103
203
  #dashboardModules;
104
204
  #configurations;
@@ -106,9 +206,10 @@ export class BotControlService {
106
206
  #permissions;
107
207
  #services;
108
208
  #privacyProviders;
109
- constructor(database, modules, resources, services, privacyExecutor) {
209
+ constructor(database, modules, resources, services, privacyExecutor, supportNotifications) {
110
210
  this.database = database;
111
211
  this.resources = resources;
212
+ this.supportNotifications = supportNotifications;
112
213
  this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
113
214
  this.#dashboardModules = modules
114
215
  .filter((module) => module.manifest.dashboard !== undefined)
@@ -126,6 +227,40 @@ export class BotControlService {
126
227
  })
127
228
  : []);
128
229
  }
230
+ async sendSupportNotification(input) {
231
+ if (!this.supportNotifications)
232
+ throw new BotControlNotFoundError("The support notification channel is not configured");
233
+ if (!this.resources.sendPrivateSupportNotification)
234
+ throw new BotControlTransportError(503, "Support notification delivery is temporarily unavailable");
235
+ const caseType = {
236
+ bug: "Bug",
237
+ general_support: "General support",
238
+ security: "Security report",
239
+ }[input.caseType];
240
+ const source = input.source === "discord_command" ? "/bug" : "Dashboard";
241
+ const severity = input.reportedSeverity
242
+ ? `${input.reportedSeverity[0].toUpperCase()}${input.reportedSeverity.slice(1)}`
243
+ : "Untriaged";
244
+ await this.resources.sendPrivateSupportNotification(this.supportNotifications.guildId, this.supportNotifications.channelId, {
245
+ embed: {
246
+ title: input.caseType === "security" ||
247
+ input.reportedSeverity === "critical"
248
+ ? "Urgent support case received"
249
+ : "New support case",
250
+ description: [
251
+ `**${input.reference}**`,
252
+ `Case type: ${caseType}`,
253
+ `Category or module: ${input.categoryOrModule}`,
254
+ `Severity: ${severity}`,
255
+ `Source: ${source}`,
256
+ ].join("\n"),
257
+ color: input.caseType === "security" ||
258
+ input.reportedSeverity === "critical"
259
+ ? 0xdc2626
260
+ : 0x7c3aed,
261
+ },
262
+ }, input.notificationId);
263
+ }
129
264
  listPrivacyProviders() {
130
265
  return Promise.resolve([...this.#privacyProviders.values()]
131
266
  .map((provider) => ({
@@ -391,6 +526,7 @@ export class BotControlService {
391
526
  if (!(await new InstallationRepository(this.database).isModuleEnabled(input.guildId, input.moduleId))) {
392
527
  throw new BotControlValidationError("Enable this module before running its dashboard actions");
393
528
  }
529
+ await this.#authorizeAction(input.guildId, module, input.actorUserId, dashboardActionPermissionId(module, input.actionId));
394
530
  return action.execute({
395
531
  guildId: input.guildId,
396
532
  actorUserId: input.actorUserId,
@@ -400,6 +536,151 @@ export class BotControlService {
400
536
  deleteChannelMessage: (channelId, messageId) => this.resources.deleteChannelMessage(channelId, messageId),
401
537
  });
402
538
  }
539
+ async listManagedResources(input) {
540
+ const { module, resource } = await this.#prepareManagedResource(input);
541
+ this.#requireManagedResourceOperation(module, input.resourceType, "list");
542
+ this.#validateManagedResourceQuery(module, input.resourceType, input);
543
+ const result = await resource.list(await this.#managedResourceContext(input, module, "list"), {
544
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
545
+ limit: input.limit,
546
+ ...(input.search === undefined ? {} : { search: input.search }),
547
+ ...(input.filters === undefined ? {} : { filters: input.filters }),
548
+ });
549
+ return normalizeManagedResourcePage(managedResourcePageSchema.parse(result), managedResourceColumnKeys(module, input.resourceType));
550
+ }
551
+ async readManagedResource(input) {
552
+ const { module, resource } = await this.#prepareManagedResource(input);
553
+ this.#requireManagedResourceOperation(module, input.resourceType, "read");
554
+ const result = await resource.read(await this.#managedResourceContext(input, module, "read"), input.resourceId);
555
+ if (!result)
556
+ throw new BotControlNotFoundError("Managed resource was not found");
557
+ return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(result), managedResourceColumnKeys(module, input.resourceType));
558
+ }
559
+ async listManagedResourceRelation(input) {
560
+ const { module, resource } = await this.#prepareManagedResource(input);
561
+ this.#requireManagedResourceOperation(module, input.resourceType, "read");
562
+ const relation = module.manifest.dashboard?.resources
563
+ ?.find(({ id }) => id === input.resourceType)
564
+ ?.relations?.find(({ id }) => id === input.relationId);
565
+ if (!relation || !resource.listRelation)
566
+ throw new BotControlNotFoundError("Managed resource relation is not declared by this module");
567
+ const context = await this.#managedResourceContext(input, module, "read", relation.permissionId);
568
+ return normalizeManagedResourceRelationPage(managedResourceRelationPageSchema.parse(await resource.listRelation(context, input.resourceId, input.relationId, {
569
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
570
+ limit: input.limit,
571
+ })), relation.columns.map(({ key }) => key));
572
+ }
573
+ async previewManagedResource(input) {
574
+ const { module, resource } = await this.#prepareManagedResource(input);
575
+ this.#requireManagedResourceOperation(module, input.resourceType, "preview");
576
+ return resource.preview(await this.#managedResourceContext(input, module, "preview"), input.value);
577
+ }
578
+ async createManagedResource(input) {
579
+ const { module, resource } = await this.#prepareManagedResource(input);
580
+ this.#requireManagedResourceOperation(module, input.resourceType, "create");
581
+ const context = await this.#managedResourceContext(input, module, "create");
582
+ const declaredIntents = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType)?.creationIntents ?? ["publish"];
583
+ if (!declaredIntents.includes(input.intent))
584
+ throw new DashboardActionValidationError("This creation intent is not declared by the module.");
585
+ return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.create(context, input.value, input.intent)), managedResourceColumnKeys(module, input.resourceType));
586
+ }
587
+ async updateManagedResource(input) {
588
+ const { module, resource } = await this.#prepareManagedResource(input);
589
+ this.#requireManagedResourceOperation(module, input.resourceType, "update");
590
+ return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.update(await this.#managedResourceContext(input, module, "update"), {
591
+ resourceId: input.resourceId,
592
+ expectedRevision: input.expectedRevision,
593
+ value: input.value,
594
+ })), managedResourceColumnKeys(module, input.resourceType));
595
+ }
596
+ async deleteManagedResource(input) {
597
+ const { module, resource } = await this.#prepareManagedResource(input);
598
+ this.#requireManagedResourceOperation(module, input.resourceType, "delete");
599
+ return managedResourceDeleteResultSchema.parse(await resource.delete(await this.#managedResourceContext(input, module, "delete"), {
600
+ resourceId: input.resourceId,
601
+ expectedRevision: input.expectedRevision,
602
+ }));
603
+ }
604
+ async executeManagedResourceAction(input) {
605
+ const module = this.#requireDashboardModule(input.moduleId);
606
+ const declared = module.manifest.dashboard?.resources
607
+ ?.find(({ id }) => id === input.resourceType)
608
+ ?.actions?.find(({ id }) => id === input.actionId);
609
+ const resource = this.#requireManagedResource(module, input.resourceType);
610
+ this.#requireManagedResourceOperation(module, input.resourceType, "action");
611
+ if (!declared)
612
+ throw new BotControlNotFoundError("Managed resource action is not declared by this module");
613
+ await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
614
+ const permissionId = managedResourcePermissionId(module, input.resourceType, "action", input.actionId, declared.permissionId);
615
+ const context = await this.#managedResourceContext(input, module, "action", permissionId);
616
+ validateManagedResourceActionValue(declared.inputs ?? [], input.value);
617
+ const result = await resource.executeAction(context, {
618
+ resourceId: input.resourceId,
619
+ actionId: input.actionId,
620
+ expectedRevision: input.expectedRevision,
621
+ value: input.value,
622
+ });
623
+ return result === null
624
+ ? null
625
+ : normalizeManagedResourceDetail(managedResourceDetailSchema.parse(result), managedResourceColumnKeys(module, input.resourceType));
626
+ }
627
+ async #prepareManagedResource(input) {
628
+ const module = this.#requireDashboardModule(input.moduleId);
629
+ const resource = this.#requireManagedResource(module, input.resourceType);
630
+ await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
631
+ return { module, resource };
632
+ }
633
+ #requireManagedResource(module, resourceType) {
634
+ const declared = module.manifest.dashboard?.resources?.some(({ id }) => id === resourceType);
635
+ const resource = module.managedResources?.find(({ id }) => id === resourceType);
636
+ if (!declared || !resource)
637
+ throw new BotControlNotFoundError("Managed resource is not declared by this module");
638
+ return resource;
639
+ }
640
+ #requireManagedResourceOperation(module, resourceType, operation) {
641
+ const declared = module.manifest.dashboard?.resources?.find(({ id }) => id === resourceType);
642
+ if (!declared ||
643
+ (declared.operations !== undefined &&
644
+ !declared.operations.includes(operation)))
645
+ throw new BotControlNotFoundError("Managed resource operation is not declared by this module");
646
+ }
647
+ #validateManagedResourceQuery(module, resourceType, input) {
648
+ const query = module.manifest.dashboard?.resources?.find(({ id }) => id === resourceType)?.query;
649
+ if (input.search !== undefined && !query?.search)
650
+ throw new BotControlValidationError("Managed resource search is not declared by this module");
651
+ for (const [key, value] of Object.entries(input.filters ?? {})) {
652
+ const filter = query?.filters?.find((candidate) => candidate.key === key);
653
+ if (!filter)
654
+ throw new BotControlValidationError("Managed resource filter is not declared by this module");
655
+ if (filter.type === "select" &&
656
+ !filter.options.some((option) => option.value === value))
657
+ throw new BotControlValidationError("Managed resource filter value is not declared by this module");
658
+ }
659
+ }
660
+ async #requireEnabledManagedResource(guildId, moduleIdValue) {
661
+ if (!(await new InstallationRepository(this.database).isModuleEnabled(guildId, moduleIdValue)))
662
+ throw new BotControlValidationError("Enable this module before managing its resources");
663
+ }
664
+ async #managedResourceContext(input, module, operation, permissionId = managedResourcePermissionId(module, input.resourceType, operation)) {
665
+ const actor = await this.#authorizeAction(input.guildId, module, input.actorUserId, permissionId);
666
+ return {
667
+ guildId: input.guildId,
668
+ actor,
669
+ correlationId: input.correlationId,
670
+ services: this.#services,
671
+ sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
672
+ deleteChannelMessage: (channelId, messageId) => this.resources.deleteChannelMessage(channelId, messageId),
673
+ editChannelMessage: (channelId, messageId, response) => this.resources.editChannelMessage(channelId, messageId, response),
674
+ addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => this.resources.addChannelMessageReaction(channelId, messageId, emojiIdentifier),
675
+ };
676
+ }
677
+ async #authorizeAction(guildId, module, actorUserId, permissionId) {
678
+ const actor = await this.resources.resolveActor(guildId, actorUserId);
679
+ const policy = await this.#permissions.getEffectivePolicy(guildId, module.manifest.id, permissionId);
680
+ if (!canUseCommand(actor, policy))
681
+ throw new BotControlForbiddenError();
682
+ return actor;
683
+ }
403
684
  #requireModule(moduleIdValue) {
404
685
  const module = this.#modules.get(moduleIdValue);
405
686
  if (!module)
@@ -570,6 +851,28 @@ export async function createBotControlServer(input) {
570
851
  };
571
852
  });
572
853
  server.get("/v1/privacy/providers", () => input.service.listPrivacyProviders());
854
+ server.post("/v1/support/notifications", async (request, reply) => {
855
+ const body = z
856
+ .object({
857
+ notificationId: z.uuid(),
858
+ reference: z.string().trim().min(1).max(40),
859
+ caseType: z.enum(["bug", "general_support", "security"]),
860
+ categoryOrModule: z.string().trim().min(1).max(100),
861
+ reportedSeverity: z
862
+ .enum(["low", "medium", "high", "critical"])
863
+ .nullable(),
864
+ source: z.enum([
865
+ "dashboard_bug",
866
+ "dashboard_support",
867
+ "dashboard_security",
868
+ "discord_command",
869
+ ]),
870
+ })
871
+ .strict()
872
+ .parse(request.body);
873
+ await input.service.sendSupportNotification(body);
874
+ return reply.code(204).send();
875
+ });
573
876
  server.post("/v1/privacy/capture", async (request) => {
574
877
  const body = z
575
878
  .object({
@@ -630,6 +933,181 @@ export async function createBotControlServer(input) {
630
933
  const body = mutationContext.parse(request.body);
631
934
  return input.service.executeDashboardAction({ ...params, ...body });
632
935
  });
936
+ server.get("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType", async (request) => {
937
+ const params = z
938
+ .object({
939
+ guildId: snowflake,
940
+ moduleId,
941
+ resourceType: resourceActionId,
942
+ })
943
+ .strict()
944
+ .parse(request.params);
945
+ const query = z
946
+ .object({
947
+ cursor: resourceCursor.optional(),
948
+ limit: z.coerce.number().int().min(1).max(50).default(50),
949
+ search: z.string().trim().min(1).max(200).optional(),
950
+ filters: z
951
+ .preprocess((value) => {
952
+ if (typeof value !== "string")
953
+ return value;
954
+ if (value.length > 2_048)
955
+ return value;
956
+ try {
957
+ return JSON.parse(value);
958
+ }
959
+ catch {
960
+ return value;
961
+ }
962
+ }, managedResourceFilters)
963
+ .optional(),
964
+ actorUserId: snowflake,
965
+ correlationId: z.uuid(),
966
+ })
967
+ .strict()
968
+ .parse(request.query);
969
+ return input.service.listManagedResources({
970
+ ...params,
971
+ ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
972
+ ...(query.search === undefined ? {} : { search: query.search }),
973
+ ...(query.filters === undefined ? {} : { filters: query.filters }),
974
+ limit: query.limit,
975
+ actorUserId: query.actorUserId,
976
+ correlationId: query.correlationId,
977
+ });
978
+ });
979
+ server.get("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId", async (request) => {
980
+ const params = z
981
+ .object({
982
+ guildId: snowflake,
983
+ moduleId,
984
+ resourceType: resourceActionId,
985
+ resourceId,
986
+ })
987
+ .strict()
988
+ .parse(request.params);
989
+ const query = mutationContext.strict().parse(request.query);
990
+ return input.service.readManagedResource({ ...params, ...query });
991
+ });
992
+ server.get("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId/relations/:relationId", async (request) => {
993
+ const params = z
994
+ .object({
995
+ guildId: snowflake,
996
+ moduleId,
997
+ resourceType: resourceActionId,
998
+ resourceId,
999
+ relationId: resourceActionId,
1000
+ })
1001
+ .strict()
1002
+ .parse(request.params);
1003
+ const query = z
1004
+ .object({
1005
+ cursor: resourceCursor.optional(),
1006
+ limit: z.coerce.number().int().min(1).max(50).default(50),
1007
+ actorUserId: snowflake,
1008
+ correlationId: z.uuid(),
1009
+ })
1010
+ .strict()
1011
+ .parse(request.query);
1012
+ return input.service.listManagedResourceRelation({
1013
+ ...params,
1014
+ ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
1015
+ limit: query.limit,
1016
+ actorUserId: query.actorUserId,
1017
+ correlationId: query.correlationId,
1018
+ });
1019
+ });
1020
+ server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/preview", async (request) => {
1021
+ const params = z
1022
+ .object({
1023
+ guildId: snowflake,
1024
+ moduleId,
1025
+ resourceType: resourceActionId,
1026
+ })
1027
+ .strict()
1028
+ .parse(request.params);
1029
+ const body = mutationContext
1030
+ .extend({ value: managedResourceObject })
1031
+ .strict()
1032
+ .parse(request.body);
1033
+ return input.service.previewManagedResource({ ...params, ...body });
1034
+ });
1035
+ server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType", async (request) => {
1036
+ const params = z
1037
+ .object({
1038
+ guildId: snowflake,
1039
+ moduleId,
1040
+ resourceType: resourceActionId,
1041
+ })
1042
+ .strict()
1043
+ .parse(request.params);
1044
+ const body = mutationContext
1045
+ .extend({
1046
+ intent: z.enum(["draft", "publish"]).optional().default("publish"),
1047
+ value: managedResourceObject,
1048
+ })
1049
+ .strict()
1050
+ .parse(request.body);
1051
+ return input.service.createManagedResource({ ...params, ...body });
1052
+ });
1053
+ server.put("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId", async (request) => {
1054
+ const params = z
1055
+ .object({
1056
+ guildId: snowflake,
1057
+ moduleId,
1058
+ resourceType: resourceActionId,
1059
+ resourceId,
1060
+ })
1061
+ .strict()
1062
+ .parse(request.params);
1063
+ const body = mutationContext
1064
+ .extend({
1065
+ expectedRevision: z.number().int().min(0),
1066
+ value: managedResourceObject.optional().default({}),
1067
+ })
1068
+ .strict()
1069
+ .parse(request.body);
1070
+ return input.service.updateManagedResource({ ...params, ...body });
1071
+ });
1072
+ server.delete("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId", async (request) => {
1073
+ const params = z
1074
+ .object({
1075
+ guildId: snowflake,
1076
+ moduleId,
1077
+ resourceType: resourceActionId,
1078
+ resourceId,
1079
+ })
1080
+ .strict()
1081
+ .parse(request.params);
1082
+ const body = mutationContext
1083
+ .extend({ expectedRevision: z.number().int().min(0) })
1084
+ .strict()
1085
+ .parse(request.body);
1086
+ return input.service.deleteManagedResource({ ...params, ...body });
1087
+ });
1088
+ server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId/actions/:actionId", async (request) => {
1089
+ const params = z
1090
+ .object({
1091
+ guildId: snowflake,
1092
+ moduleId,
1093
+ resourceType: resourceActionId,
1094
+ resourceId,
1095
+ actionId: resourceActionId,
1096
+ })
1097
+ .strict()
1098
+ .parse(request.params);
1099
+ const body = mutationContext
1100
+ .extend({
1101
+ expectedRevision: z.number().int().min(0),
1102
+ value: managedResourceObject,
1103
+ })
1104
+ .strict()
1105
+ .parse(request.body);
1106
+ return input.service.executeManagedResourceAction({
1107
+ ...params,
1108
+ ...body,
1109
+ });
1110
+ });
633
1111
  server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {
634
1112
  const params = z
635
1113
  .object({ guildId: snowflake, moduleId })
@@ -690,6 +1168,8 @@ export function mapBotControlError(error) {
690
1168
  return { status: 400, message: error.message };
691
1169
  if (error instanceof BotControlNotFoundError)
692
1170
  return { status: 404, message: error.message };
1171
+ if (error instanceof BotControlForbiddenError)
1172
+ return { status: 403, message: error.message };
693
1173
  if (error instanceof OptimisticConcurrencyError)
694
1174
  return {
695
1175
  status: 409,
@@ -705,6 +1185,12 @@ class BotControlNotFoundError extends Error {
705
1185
  this.name = "BotControlNotFoundError";
706
1186
  }
707
1187
  }
1188
+ class BotControlForbiddenError extends Error {
1189
+ constructor(message = "You no longer have permission to perform this action") {
1190
+ super(message);
1191
+ this.name = "BotControlForbiddenError";
1192
+ }
1193
+ }
708
1194
  function digest(value) {
709
1195
  return createHash("sha256").update(value).digest();
710
1196
  }
@@ -714,6 +1200,181 @@ function isUnknownGuildError(error) {
714
1200
  "code" in error &&
715
1201
  error.code === 10_004);
716
1202
  }
1203
+ function isUnknownMemberError(error) {
1204
+ return (typeof error === "object" &&
1205
+ error !== null &&
1206
+ "code" in error &&
1207
+ error.code === 10_007);
1208
+ }
1209
+ function dashboardActionPermissionId(module, actionId) {
1210
+ const namespace = modulePermissionNamespace(module);
1211
+ return selectDeclaredPermission(module, [
1212
+ `${namespace}.${actionId.replaceAll("-", ".")}`,
1213
+ `${namespace}.configure`,
1214
+ ]);
1215
+ }
1216
+ function managedResourcePermissionId(module, resourceType, operation, actionId, declaredPermissionId) {
1217
+ if (declaredPermissionId)
1218
+ return declaredPermissionId;
1219
+ const namespace = modulePermissionNamespace(module);
1220
+ const resourcePermission = `resource.${resourceType}`;
1221
+ const candidates = operation === "create" || operation === "preview"
1222
+ ? [`${resourcePermission}.create`, `${namespace}.create`]
1223
+ : operation === "update"
1224
+ ? [
1225
+ `${resourcePermission}.update`,
1226
+ `${namespace}.edit`,
1227
+ `${namespace}.manage`,
1228
+ ]
1229
+ : operation === "delete"
1230
+ ? [
1231
+ `${resourcePermission}.delete`,
1232
+ `${namespace}.delete`,
1233
+ `${namespace}.cancel`,
1234
+ ]
1235
+ : operation === "action" && actionId
1236
+ ? [
1237
+ `${resourcePermission}.${actionId}`,
1238
+ `${resourcePermission}.action.${actionId}`,
1239
+ `${resourcePermission}.action`,
1240
+ `${namespace}.${actionId.replaceAll("-", ".")}`,
1241
+ `${namespace}.manage`,
1242
+ ]
1243
+ : [
1244
+ `${resourcePermission}.${operation}`,
1245
+ `${namespace}.read`,
1246
+ `${namespace}.list`,
1247
+ `${namespace}.manage`,
1248
+ `${namespace}.entries.read`,
1249
+ ];
1250
+ return selectDeclaredPermission(module, candidates);
1251
+ }
1252
+ function modulePermissionNamespace(module) {
1253
+ return module.manifest.id.startsWith("helyx.")
1254
+ ? module.manifest.id.slice("helyx.".length)
1255
+ : module.manifest.id;
1256
+ }
1257
+ function selectDeclaredPermission(module, candidates) {
1258
+ const declared = module.defaultPermissions?.commands ?? {};
1259
+ return (candidates.find((candidate) => candidate in declared) ?? candidates[0]);
1260
+ }
1261
+ function isBoundedManagedResourceValue(value, depth = 0, budget = { keys: 0 }) {
1262
+ if (depth > 8)
1263
+ return false;
1264
+ if (value === null ||
1265
+ typeof value === "boolean" ||
1266
+ (typeof value === "number" && Number.isFinite(value)))
1267
+ return true;
1268
+ if (typeof value === "string")
1269
+ return value.length <= 4_000;
1270
+ if (Array.isArray(value))
1271
+ return (value.length <= 100 &&
1272
+ value.every((item) => isBoundedManagedResourceValue(item, depth + 1, budget)));
1273
+ if (typeof value !== "object")
1274
+ return false;
1275
+ const prototype = Object.getPrototypeOf(value);
1276
+ if (prototype !== Object.prototype && prototype !== null)
1277
+ return false;
1278
+ const entries = Object.entries(value);
1279
+ budget.keys += entries.length;
1280
+ return (entries.length <= 100 &&
1281
+ budget.keys <= 500 &&
1282
+ entries.every(([key, item]) => key.length >= 1 &&
1283
+ key.length <= 100 &&
1284
+ isBoundedManagedResourceValue(item, depth + 1, budget)));
1285
+ }
1286
+ function hasBoundedManagedResourceBytes(value) {
1287
+ return (new TextEncoder().encode(JSON.stringify(value)).byteLength <= 48 * 1_024);
1288
+ }
1289
+ function normalizeManagedResourceDetail(value, attributeKeys) {
1290
+ return {
1291
+ id: value.id,
1292
+ revision: value.revision,
1293
+ status: value.status,
1294
+ title: value.title,
1295
+ ...(value.description === undefined
1296
+ ? {}
1297
+ : { description: value.description }),
1298
+ ...(value.attributes === undefined || attributeKeys.length === 0
1299
+ ? {}
1300
+ : {
1301
+ attributes: projectManagedResourceObject(value.attributes, attributeKeys),
1302
+ }),
1303
+ updatedAt: value.updatedAt,
1304
+ value: value.value,
1305
+ };
1306
+ }
1307
+ function normalizeManagedResourcePage(value, attributeKeys) {
1308
+ return {
1309
+ items: value.items.map((item) => ({
1310
+ id: item.id,
1311
+ revision: item.revision,
1312
+ status: item.status,
1313
+ title: item.title,
1314
+ ...(item.description === undefined
1315
+ ? {}
1316
+ : { description: item.description }),
1317
+ ...(item.attributes === undefined || attributeKeys.length === 0
1318
+ ? {}
1319
+ : {
1320
+ attributes: projectManagedResourceObject(item.attributes, attributeKeys),
1321
+ }),
1322
+ updatedAt: item.updatedAt,
1323
+ })),
1324
+ ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }),
1325
+ };
1326
+ }
1327
+ function normalizeManagedResourceRelationPage(value, valueKeys) {
1328
+ return {
1329
+ items: value.items.map((item) => ({
1330
+ id: item.id,
1331
+ value: projectManagedResourceObject(item.value, valueKeys),
1332
+ })),
1333
+ ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }),
1334
+ };
1335
+ }
1336
+ function projectManagedResourceObject(value, declaredKeys) {
1337
+ const allowed = new Set(declaredKeys);
1338
+ return Object.fromEntries(Object.entries(value)
1339
+ .filter(([key]) => allowed.has(key))
1340
+ .map(([key, item]) => [key, structuredClone(item)]));
1341
+ }
1342
+ function managedResourceColumnKeys(module, resourceType) {
1343
+ return (module.manifest.dashboard?.resources
1344
+ ?.find(({ id }) => id === resourceType)
1345
+ ?.columns?.map(({ key }) => key) ?? []);
1346
+ }
1347
+ function validateManagedResourceActionValue(inputs, value) {
1348
+ const declared = new Map(inputs.map((input) => [input.key, input]));
1349
+ for (const key of Object.keys(value))
1350
+ if (!declared.has(key))
1351
+ throw new DashboardActionValidationError(`The action value "${key}" is not declared by the module.`);
1352
+ for (const input of inputs) {
1353
+ const item = value[input.key];
1354
+ if (item === undefined) {
1355
+ if (input.required)
1356
+ throw new DashboardActionValidationError(`${input.label} is required for this action.`);
1357
+ continue;
1358
+ }
1359
+ const valid = input.type === "boolean"
1360
+ ? typeof item === "boolean"
1361
+ : input.type === "integer"
1362
+ ? typeof item === "number" &&
1363
+ Number.isInteger(item) &&
1364
+ item >= input.minimum &&
1365
+ item <= input.maximum
1366
+ : input.type === "select"
1367
+ ? typeof item === "string" &&
1368
+ input.options.some(({ value: option }) => option === item)
1369
+ : input.type === "discord-channel" || input.type === "discord-role"
1370
+ ? typeof item === "string" && snowflake.safeParse(item).success
1371
+ : typeof item === "string" &&
1372
+ item.length >= input.minLength &&
1373
+ item.length <= input.maxLength;
1374
+ if (!valid)
1375
+ throw new DashboardActionValidationError(`${input.label} is not a valid action value.`);
1376
+ }
1377
+ }
717
1378
  function mapDashboardModule(module) {
718
1379
  const dashboard = module.manifest.dashboard;
719
1380
  return {
@@ -730,12 +1391,15 @@ function mapDashboardModule(module) {
730
1391
  commands: dashboardCommands(module),
731
1392
  logEvents: (module.logging?.events ?? []).map((event) => ({ ...event })),
732
1393
  actions: (dashboard.actions ?? []).map((action) => ({ ...action })),
1394
+ resources: (dashboard.resources ?? []).map((resource) => structuredClone(resource)),
733
1395
  };
734
1396
  }
735
1397
  function dashboardCommands(module) {
736
- return (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
1398
+ const commands = (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
737
1399
  ? command.subcommands.map((subcommand) => {
738
- const id = `${command.name}.${subcommand.name}`;
1400
+ const id = subcommand.permissionId ??
1401
+ command.permissionId ??
1402
+ `${command.name}.${subcommand.name}`;
739
1403
  return {
740
1404
  id,
741
1405
  name: `/${command.name} ${subcommand.name}`,
@@ -747,7 +1411,7 @@ function dashboardCommands(module) {
747
1411
  })
748
1412
  : [
749
1413
  {
750
- id: command.name,
1414
+ id: command.permissionId ?? command.name,
751
1415
  name: `/${command.name}`,
752
1416
  description: command.description,
753
1417
  defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[command.name] ??
@@ -755,6 +1419,9 @@ function dashboardCommands(module) {
755
1419
  emptyPolicy()).preset,
756
1420
  },
757
1421
  ]);
1422
+ return [
1423
+ ...new Map(commands.map((command) => [command.id, command])).values(),
1424
+ ];
758
1425
  }
759
1426
  function toDashboardPolicy(policy) {
760
1427
  const preset = policy.everyone