@helyx/bot 0.4.8 → 0.4.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.11
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the reusable Stage 8 Levels foundations: privacy-bounded guild message
8
+ events with optional transient type classification, optional Discord-user
9
+ command options, multiple channel settings, bounded ranked numeric records,
10
+ idempotent numeric mutation receipts, and core-owned write-behind accumulation
11
+ across local and coordinated shard compositions.
12
+ - Updated dependencies
13
+ - @helyx/database@0.5.0
14
+ - @helyx/module-manifest@0.5.0
15
+ - @helyx/control-protocol@0.2.0
16
+ - @helyx/sdk@0.4.0
17
+ - @helyx/platform@0.3.0
18
+ - @helyx/core@0.1.11
19
+ - @helyx/framework@0.1.11
20
+
21
+ ## 0.4.10
22
+
23
+ ### Patch Changes
24
+
25
+ - Add the shared session, Discord event, resource inspection, enforcement,
26
+ privacy and hosted/self-hosted composition support required by the Account Gate
27
+ Testing module.
28
+ - Updated dependencies
29
+ - @helyx/config@0.4.3
30
+ - @helyx/database@0.4.4
31
+ - @helyx/sdk@0.3.5
32
+ - @helyx/platform@0.2.8
33
+ - @helyx/core@0.1.10
34
+ - @helyx/framework@0.1.10
35
+
36
+ ## 0.4.9
37
+
38
+ ### Patch Changes
39
+
40
+ - Polish the Stage 4-6 resource dashboard and Discord flows, add durable
41
+ reaction-role controls, repair poll and giveaway lifecycle handling, and
42
+ strengthen shard-safe scheduling, retention, permissions and resource
43
+ inspection.
44
+ - Updated dependencies
45
+ - @helyx/control-protocol@0.1.5
46
+ - @helyx/module-manifest@0.4.2
47
+ - @helyx/database@0.4.3
48
+ - @helyx/sdk@0.3.4
49
+ - @helyx/platform@0.2.7
50
+ - @helyx/core@0.1.9
51
+ - @helyx/framework@0.1.9
52
+
3
53
  ## 0.4.8
4
54
 
5
55
  ### Patch Changes
@@ -146,11 +146,12 @@ export class CloudControlDispatcher {
146
146
  break;
147
147
  }
148
148
  case "guild.module.resource.action.execute.v1": {
149
- const input = parseOperationInput(request.operation, request.input);
149
+ const { actorAuthenticatedAt, ...input } = parseOperationInput(request.operation, request.input);
150
150
  result = await this.control.executeManagedResourceAction({
151
151
  ...input,
152
152
  actorUserId: requiredActor(actorUserId),
153
153
  correlationId,
154
+ ...(actorAuthenticatedAt ? { actorAuthenticatedAt } : {}),
154
155
  });
155
156
  break;
156
157
  }
@@ -104,6 +104,9 @@ export interface SupportNotificationInput {
104
104
  source: "dashboard_bug" | "dashboard_support" | "dashboard_security" | "discord_command";
105
105
  }
106
106
  export interface BotControlState {
107
+ discordCapabilities?: {
108
+ messageContent: boolean;
109
+ };
107
110
  channels: BotControlGuildResources["channels"];
108
111
  roles: BotControlGuildResources["roles"];
109
112
  modules: DashboardModuleDefinition[];
@@ -237,6 +240,7 @@ export interface BotControlHandler {
237
240
  value: Readonly<Record<string, ManagedResourceValue>>;
238
241
  actorUserId: string;
239
242
  correlationId: string;
243
+ actorAuthenticatedAt?: string;
240
244
  }): Promise<ManagedResourceDetail | null>;
241
245
  listPrivacyProviders(): Promise<readonly {
242
246
  id: string;
@@ -415,6 +419,7 @@ export declare class BotControlService implements BotControlHandler {
415
419
  value: Readonly<Record<string, ManagedResourceValue>>;
416
420
  actorUserId: string;
417
421
  correlationId: string;
422
+ actorAuthenticatedAt?: string;
418
423
  }): Promise<ManagedResourceDetail | null>;
419
424
  }
420
425
  export declare function createBotControlServer(input: {
@@ -9,7 +9,9 @@ import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteCh
9
9
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
10
10
  const snowflake = z.string().regex(/^\d{17,20}$/u);
11
11
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
12
+ const MAX_DISCORD_SETTING_SELECTIONS = 25;
12
13
  const configurationValue = z.record(z.string(), z.union([
14
+ z.null(),
13
15
  z.string(),
14
16
  z.boolean(),
15
17
  z.number().finite(),
@@ -222,7 +224,7 @@ export class BotControlService {
222
224
  ? modules
223
225
  .filter((module) => module.privacy !== undefined)
224
226
  .map((module) => {
225
- const provider = module.privacy.create(privacyExecutor);
227
+ const provider = module.privacy.create(privacyExecutor, this.#services);
226
228
  return [provider.id, provider];
227
229
  })
228
230
  : []);
@@ -289,10 +291,11 @@ export class BotControlService {
289
291
  });
290
292
  }
291
293
  async getGuildState(guildId) {
292
- const [resources, installedModules, storedLoggingPolicies] = await Promise.all([
294
+ const [resources, installedModules, storedLoggingPolicies, runtime] = await Promise.all([
293
295
  this.resources.getGuildResources(guildId),
294
296
  new InstallationRepository(this.database).listModules(guildId),
295
297
  new ModuleLoggingPolicyRepository(this.database).listForGuild(guildId),
298
+ this.#inspectRuntimeCapabilities(),
296
299
  ]);
297
300
  const installed = new Map(installedModules.map((module) => [module.moduleId, module]));
298
301
  const moduleStatuses = {};
@@ -331,6 +334,9 @@ export class BotControlService {
331
334
  });
332
335
  }));
333
336
  return {
337
+ discordCapabilities: {
338
+ messageContent: runtime.messageContent,
339
+ },
334
340
  ...resources,
335
341
  modules: structuredClone(this.#dashboardModules),
336
342
  moduleStatuses,
@@ -339,9 +345,29 @@ export class BotControlService {
339
345
  logging,
340
346
  };
341
347
  }
348
+ async #inspectRuntimeCapabilities() {
349
+ if (!this.#services.has(HELYX_SERVICE_NAMES.discordResources))
350
+ return { messageContent: false };
351
+ const inspector = this.#services.get(HELYX_SERVICE_NAMES.discordResources);
352
+ if (typeof inspector.inspectRuntimeCapabilities !== "function")
353
+ return { messageContent: false };
354
+ const runtime = await inspector.inspectRuntimeCapabilities();
355
+ return { messageContent: runtime.messageContent };
356
+ }
342
357
  async setModuleStatus(input) {
343
- this.#requireDashboardModule(input.moduleId);
344
- await this.#installations.setModuleEnabled({
358
+ const module = this.#requireDashboardModule(input.moduleId);
359
+ const scopes = Object.entries(module.records ?? {})
360
+ .filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
361
+ .map(([collection]) => ({
362
+ moduleId: input.moduleId,
363
+ collection,
364
+ guildId: input.guildId,
365
+ }));
366
+ const writeBehind = scopes.length > 0 &&
367
+ this.#services.has(HELYX_SERVICE_NAMES.numericWriteBehind)
368
+ ? this.#services.get(HELYX_SERVICE_NAMES.numericWriteBehind)
369
+ : null;
370
+ const setEnabled = () => this.#installations.setModuleEnabled({
345
371
  guildId: input.guildId,
346
372
  moduleId: input.moduleId,
347
373
  enabled: input.enabled,
@@ -351,11 +377,56 @@ export class BotControlService {
351
377
  source: "dashboard",
352
378
  },
353
379
  });
380
+ if (!writeBehind) {
381
+ await setEnabled();
382
+ }
383
+ else if (input.enabled) {
384
+ for (const scope of scopes) {
385
+ const suppression = await writeBehind.suppressGuild(scope);
386
+ await writeBehind.releaseGuild({
387
+ ...scope,
388
+ suppressionId: suppression.suppressionId,
389
+ });
390
+ }
391
+ await setEnabled();
392
+ }
393
+ else {
394
+ const runBoundary = async (index) => {
395
+ const scope = scopes[index];
396
+ if (!scope) {
397
+ await setEnabled();
398
+ return;
399
+ }
400
+ await writeBehind.runGuildBarrier(scope, () => runBoundary(index + 1));
401
+ };
402
+ await runBoundary(0);
403
+ for (const scope of scopes)
404
+ await writeBehind.flushGuild(scope);
405
+ for (const scope of scopes)
406
+ await writeBehind.suppressGuild(scope);
407
+ }
354
408
  return this.getGuildState(input.guildId);
355
409
  }
356
410
  async updateConfiguration(input) {
357
411
  const module = this.#requireDashboardModule(input.moduleId);
358
412
  await this.#validateDiscordSettings(input.guildId, module, input.value);
413
+ if (module.configuration?.validateUpdate) {
414
+ try {
415
+ const value = module.configuration.validate(input.value);
416
+ await module.configuration.validateUpdate({
417
+ guildId: input.guildId,
418
+ value,
419
+ services: this.#services,
420
+ });
421
+ }
422
+ catch (error) {
423
+ if (error instanceof ModuleStateValidationError)
424
+ throw error;
425
+ throw new DashboardActionValidationError(error instanceof Error
426
+ ? error.message
427
+ : "The module rejected these settings.");
428
+ }
429
+ }
359
430
  const saved = await this.#configurations.update({
360
431
  guildId: input.guildId,
361
432
  moduleId: input.moduleId,
@@ -602,15 +673,13 @@ export class BotControlService {
602
673
  }));
603
674
  }
604
675
  async executeManagedResourceAction(input) {
605
- const module = this.#requireDashboardModule(input.moduleId);
676
+ const { module, resource } = await this.#prepareManagedResource(input);
606
677
  const declared = module.manifest.dashboard?.resources
607
678
  ?.find(({ id }) => id === input.resourceType)
608
679
  ?.actions?.find(({ id }) => id === input.actionId);
609
- const resource = this.#requireManagedResource(module, input.resourceType);
610
680
  this.#requireManagedResourceOperation(module, input.resourceType, "action");
611
681
  if (!declared)
612
682
  throw new BotControlNotFoundError("Managed resource action is not declared by this module");
613
- await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
614
683
  const permissionId = managedResourcePermissionId(module, input.resourceType, "action", input.actionId, declared.permissionId);
615
684
  const context = await this.#managedResourceContext(input, module, "action", permissionId);
616
685
  validateManagedResourceActionValue(declared.inputs ?? [], input.value);
@@ -627,7 +696,8 @@ export class BotControlService {
627
696
  async #prepareManagedResource(input) {
628
697
  const module = this.#requireDashboardModule(input.moduleId);
629
698
  const resource = this.#requireManagedResource(module, input.resourceType);
630
- await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
699
+ if (!resource.availableWhileDisabled)
700
+ await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
631
701
  return { module, resource };
632
702
  }
633
703
  #requireManagedResource(module, resourceType) {
@@ -666,6 +736,10 @@ export class BotControlService {
666
736
  return {
667
737
  guildId: input.guildId,
668
738
  actor,
739
+ ...("actorAuthenticatedAt" in input &&
740
+ typeof input.actorAuthenticatedAt === "string"
741
+ ? { actorAuthenticatedAt: input.actorAuthenticatedAt }
742
+ : {}),
669
743
  correlationId: input.correlationId,
670
744
  services: this.#services,
671
745
  sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
@@ -733,14 +807,25 @@ export class BotControlService {
733
807
  if (setting.type === "discord-channel") {
734
808
  if (configured === "" || configured === undefined)
735
809
  continue;
736
- const channel = typeof configured === "string" ? channels.get(configured) : null;
737
- if (!channel || !setting.channelTypes.includes(channel.type))
738
- throw new BotControlValidationError(`${setting.label} must be an available supported Discord channel`);
810
+ if (Boolean(setting.multiple) !== Array.isArray(configured))
811
+ throw new BotControlValidationError(`${setting.label} has an invalid Discord channel selection`);
812
+ const configuredChannels = Array.isArray(configured)
813
+ ? configured
814
+ : [configured];
815
+ if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
816
+ new Set(configuredChannels).size !== configuredChannels.length ||
817
+ configuredChannels.some((channelId) => {
818
+ if (typeof channelId !== "string")
819
+ return true;
820
+ const channel = channels.get(channelId);
821
+ return !channel || !setting.channelTypes.includes(channel.type);
822
+ }))
823
+ throw new BotControlValidationError(`${setting.label} must contain available supported Discord channels`);
739
824
  }
740
825
  if (setting.type === "discord-role") {
741
826
  if (configured === "" || configured === undefined)
742
827
  continue;
743
- if (setting.multiple !== Array.isArray(configured))
828
+ if (Boolean(setting.multiple) !== Array.isArray(configured))
744
829
  throw new BotControlValidationError(`${setting.label} has an invalid Discord role selection`);
745
830
  const configuredRoles = Array.isArray(configured)
746
831
  ? configured
@@ -1099,13 +1184,20 @@ export async function createBotControlServer(input) {
1099
1184
  const body = mutationContext
1100
1185
  .extend({
1101
1186
  expectedRevision: z.number().int().min(0),
1102
- value: managedResourceObject,
1187
+ value: managedResourceObject.optional().default({}),
1188
+ actorAuthenticatedAt: z.iso.datetime({ offset: true }).optional(),
1103
1189
  })
1104
1190
  .strict()
1105
1191
  .parse(request.body);
1106
1192
  return input.service.executeManagedResourceAction({
1107
1193
  ...params,
1108
- ...body,
1194
+ actorUserId: body.actorUserId,
1195
+ correlationId: body.correlationId,
1196
+ expectedRevision: body.expectedRevision,
1197
+ value: body.value,
1198
+ ...(body.actorAuthenticatedAt === undefined
1199
+ ? {}
1200
+ : { actorAuthenticatedAt: body.actorAuthenticatedAt }),
1109
1201
  });
1110
1202
  });
1111
1203
  server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {
@@ -21,7 +21,7 @@ export class DiscordOwnedMessageDeletionService {
21
21
  return { cleared: false };
22
22
  const reaction = message.reactions.resolve(emoji.cacheKey);
23
23
  if (!reaction)
24
- return { cleared: false };
24
+ return { cleared: true };
25
25
  await reaction.remove();
26
26
  return { cleared: true };
27
27
  }
@@ -108,6 +108,14 @@ function prepareEmojiIdentifier(value) {
108
108
  if (value.length < 1 || value.length > 128 || /[\r\n]/u.test(value)) {
109
109
  throw new Error("Invalid bounded Discord emoji identifier");
110
110
  }
111
+ const canonicalCustom = /^custom:(\d{17,20})$/u.exec(value);
112
+ if (canonicalCustom) {
113
+ const id = canonicalCustom[1];
114
+ return { identifier: id, cacheKey: id };
115
+ }
116
+ const canonicalUnicode = /^unicode:(.+)$/u.exec(value);
117
+ if (canonicalUnicode)
118
+ value = canonicalUnicode[1];
111
119
  const custom = /^(?:<a?:([A-Za-z0-9_]{2,32}):(\d{17,20})>|([A-Za-z0-9_]{2,32}):(\d{17,20}))$/u.exec(value);
112
120
  if (custom) {
113
121
  const name = custom[1] ?? custom[3];
package/dist/events.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DiscordChannelInspection, DiscordEmojiInspection, DiscordResourceInspectionService, DiscordRoleInspection, MemberRoleService, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
1
+ import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
2
2
  import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
3
3
  import { type InteractionLogger } from "./interactions.js";
4
4
  export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): ModuleEventHandle;
@@ -15,9 +15,30 @@ interface ApprovedRoleMutation {
15
15
  moduleId: string;
16
16
  reason: string;
17
17
  }
18
+ export declare class DiscordMessageDeliveryService implements DiscordMessageService {
19
+ private readonly client;
20
+ constructor(client: Client);
21
+ send(input: Parameters<DiscordMessageService["send"]>[0]): ReturnType<DiscordMessageService["send"]>;
22
+ sendDirect(input: Parameters<DiscordMessageService["sendDirect"]>[0]): ReturnType<DiscordMessageService["sendDirect"]>;
23
+ }
24
+ export declare class DiscordMemberEnforcementService implements MemberEnforcementService {
25
+ #private;
26
+ private readonly client;
27
+ constructor(client: Client);
28
+ kick(input: Parameters<MemberEnforcementService["kick"]>[0]): ReturnType<MemberEnforcementService["kick"]>;
29
+ }
18
30
  export declare class DiscordMemberRoleService implements MemberRoleService {
19
31
  private readonly client;
20
32
  constructor(client: Client);
33
+ createApprovedRole(input: {
34
+ guildId: string;
35
+ name: string;
36
+ moduleId: string;
37
+ reason: string;
38
+ }): Promise<{
39
+ roleId: string;
40
+ name: string;
41
+ }>;
21
42
  getCurrentRoleIds(input: {
22
43
  guildId: string;
23
44
  memberUserId: string;
@@ -45,6 +66,15 @@ export declare class DiscordResourceInspection implements DiscordResourceInspect
45
66
  #private;
46
67
  private readonly client;
47
68
  constructor(client: Client);
69
+ inspectGuildCapabilities(input: {
70
+ guildId: string;
71
+ }): Promise<DiscordGuildCapabilityInspection>;
72
+ inspectRuntimeCapabilities(): Promise<{
73
+ guildMembers: boolean;
74
+ guildMessages: boolean;
75
+ guildMessageReactions: boolean;
76
+ messageContent: boolean;
77
+ }>;
48
78
  resolveMemberSnapshots(input: {
49
79
  guildId: string;
50
80
  userIds: readonly string[];
@@ -61,6 +91,17 @@ export declare class DiscordResourceInspection implements DiscordResourceInspect
61
91
  }): Promise<readonly {
62
92
  userId: string;
63
93
  displayName: string | null;
94
+ avatarUrl?: string;
95
+ }[]>;
96
+ searchMembers(input: {
97
+ guildId: string;
98
+ query: string;
99
+ limit: number;
100
+ }): Promise<readonly {
101
+ userId: string;
102
+ displayName: string;
103
+ avatarUrl?: string;
104
+ isBot: boolean;
64
105
  }[]>;
65
106
  inspectRole(input: {
66
107
  guildId: string;
@@ -75,6 +116,28 @@ export declare class DiscordResourceInspection implements DiscordResourceInspect
75
116
  guildId: string;
76
117
  channelId: string;
77
118
  }): Promise<DiscordChannelInspection>;
119
+ inspectMessage(input: {
120
+ guildId: string;
121
+ channelId: string;
122
+ messageId: string;
123
+ }): Promise<DiscordMessageInspection>;
124
+ inspectChannelAudience(input: {
125
+ guildId: string;
126
+ channelId: string;
127
+ roleIds: readonly string[];
128
+ }): Promise<DiscordChannelAudienceInspection>;
129
+ inspectGuildChannelAudience(input: {
130
+ guildId: string;
131
+ roleIds: readonly string[];
132
+ }): Promise<DiscordGuildChannelAudienceInspection>;
133
+ seedMessageReaction(input: {
134
+ guildId: string;
135
+ channelId: string;
136
+ messageId: string;
137
+ emojiIdentifier: string;
138
+ }): Promise<{
139
+ added: boolean;
140
+ }>;
78
141
  }
79
142
  export {};
80
143
  //# sourceMappingURL=events.d.ts.map