@helyx/bot 0.4.11 → 0.4.14

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.14
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @helyx/control-protocol@0.3.1
9
+
10
+ ## 0.4.13
11
+
12
+ ### Patch Changes
13
+
14
+ - Add the hosted-only Stage 11 Verification foundation: bounded signed challenge
15
+ contracts, restricted persistence and privacy traversal, shard-safe bot
16
+ delivery, delegated investigation permissions, verifier configuration and
17
+ health, and explicit exclusion from every self-hosted composition.
18
+ - Updated dependencies
19
+ - @helyx/control-protocol@0.3.0
20
+ - @helyx/module-manifest@0.6.0
21
+ - @helyx/config@0.5.0
22
+ - @helyx/database@0.6.0
23
+ - @helyx/sdk@0.5.0
24
+ - @helyx/platform@0.4.0
25
+ - @helyx/core@0.1.12
26
+ - @helyx/framework@0.1.12
27
+
28
+ ## 0.4.12
29
+
30
+ ### Patch Changes
31
+
32
+ - Repair hosted guild presence after removal, restart and shard reassignment with
33
+ bounded fenced reconciliation and customer-safe dashboard states.
34
+ - Exit with failure after an unexpected coordinated-shard lease shutdown so the
35
+ service orchestrator replaces the stopped supervisor automatically.
36
+ - Retry one transient lease-renewal transport failure within the existing
37
+ fencing window while still failing closed on an actual ownership mismatch.
38
+ - Updated dependencies
39
+ - @helyx/config@0.4.4
40
+ - @helyx/database@0.5.1
41
+ - @helyx/platform@0.3.1
42
+
3
43
  ## 0.4.11
4
44
 
5
45
  ### Patch Changes
@@ -1,5 +1,6 @@
1
- import { type DatabasePool } from "@helyx/database";
1
+ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
2
2
  import { type DashboardActionResult, type ManagedResourceCreationIntent, type ManagedResourceDetail, type ManagedResourcePage, type ManagedResourceRelationPage, type ManagedResourceValue, type MessageResponse, type ModuleDefinition, type PermissionActor, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
3
+ import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
3
4
  import { type Client } from "discord.js";
4
5
  import { type FastifyInstance } from "fastify";
5
6
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
@@ -63,7 +64,7 @@ export interface BotControlGuildResources {
63
64
  channels: Array<{
64
65
  id: string;
65
66
  name: string;
66
- type: "guild_text" | "guild_announcement";
67
+ type: "guild_text" | "guild_announcement" | "guild_category";
67
68
  }>;
68
69
  roles: Array<{
69
70
  id: string;
@@ -71,7 +72,14 @@ export interface BotControlGuildResources {
71
72
  colour: string;
72
73
  }>;
73
74
  }
75
+ export type BotGuildPresenceState = "installed" | "not_installed" | "unknown" | "unavailable";
76
+ export interface BotGuildPresenceObservation {
77
+ guildId: string;
78
+ state: BotGuildPresenceState;
79
+ observedAt: string;
80
+ }
74
81
  export interface BotControlGuildResourceDirectory {
82
+ listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
75
83
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
76
84
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
77
85
  messageId: string;
@@ -85,6 +93,7 @@ export interface BotControlGuildResourceDirectory {
85
93
  export declare class DiscordGuildResourceDirectory implements BotControlGuildResourceDirectory {
86
94
  private readonly client;
87
95
  constructor(client: Client);
96
+ listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
88
97
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
89
98
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
90
99
  messageId: string;
@@ -120,7 +129,18 @@ export interface BotControlState {
120
129
  }
121
130
  export interface BotControlHandler {
122
131
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
132
+ listGuildPresence(input: {
133
+ guildIds: readonly string[];
134
+ correlationId: string;
135
+ }): Promise<BotGuildPresenceObservation[]>;
123
136
  getGuildState(guildId: string): Promise<BotControlState>;
137
+ canAccessManagedResource(input: {
138
+ guildId: string;
139
+ moduleId: string;
140
+ resourceType: string;
141
+ operation: "list" | "read";
142
+ actorUserId: string;
143
+ }): Promise<boolean>;
124
144
  setModuleStatus(input: {
125
145
  guildId: string;
126
146
  moduleId: string;
@@ -242,6 +262,7 @@ export interface BotControlHandler {
242
262
  correlationId: string;
243
263
  actorAuthenticatedAt?: string;
244
264
  }): Promise<ManagedResourceDetail | null>;
265
+ acceptVerificationResult(envelope: SignedAssessmentEnvelope): Promise<ActionReceipt>;
245
266
  listPrivacyProviders(): Promise<readonly {
246
267
  id: string;
247
268
  version: number;
@@ -273,11 +294,18 @@ export declare class BotControlService implements BotControlHandler {
273
294
  private readonly database;
274
295
  private readonly resources;
275
296
  private readonly supportNotifications?;
297
+ private readonly presenceOwnership?;
298
+ private readonly presenceShardCount?;
276
299
  constructor(database: DatabasePool, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess, privacyExecutor?: PrivacyProviderExecutor, supportNotifications?: {
277
300
  guildId: string;
278
301
  channelId: string;
279
- } | undefined);
302
+ } | undefined, presenceOwnership?: GuildShardOwnership | undefined, presenceShardCount?: number | undefined);
303
+ listGuildPresence(input: {
304
+ guildIds: readonly string[];
305
+ correlationId: string;
306
+ }): Promise<BotGuildPresenceObservation[]>;
280
307
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
308
+ acceptVerificationResult(envelopeInput: SignedAssessmentEnvelope): Promise<ActionReceipt>;
281
309
  listPrivacyProviders(): Promise<readonly {
282
310
  id: string;
283
311
  version: number;
@@ -300,6 +328,13 @@ export declare class BotControlService implements BotControlHandler {
300
328
  cursor?: string;
301
329
  }): Promise<PrivacyErasePage>;
302
330
  getGuildState(guildId: string): Promise<BotControlState>;
331
+ canAccessManagedResource(input: {
332
+ guildId: string;
333
+ moduleId: string;
334
+ resourceType: string;
335
+ operation: "list" | "read";
336
+ actorUserId: string;
337
+ }): Promise<boolean>;
303
338
  setModuleStatus(input: {
304
339
  guildId: string;
305
340
  moduleId: string;
@@ -1,5 +1,6 @@
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 { actionReceiptSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
3
4
  import { canUseCommand } from "@helyx/core";
4
5
  import { ChannelType, PermissionFlagsBits } from "discord.js";
5
6
  import Fastify, {} from "fastify";
@@ -49,7 +50,7 @@ const resourceCursor = z.string().min(1).max(2_048);
49
50
  const resourceActionId = z.string().regex(/^[a-z][a-z0-9_-]*$/u);
50
51
  const managedResourceFilters = z
51
52
  .record(z.string().regex(/^[a-z][a-zA-Z0-9_]*$/u), z.string().trim().min(1).max(100))
52
- .refine((value) => Object.keys(value).length <= 5);
53
+ .refine((value) => Object.keys(value).length <= 8);
53
54
  const managedResourceValue = z.custom((value) => isBoundedManagedResourceValue(value), "Managed resource value exceeds the transport limits");
54
55
  const managedResourceObject = z
55
56
  .record(z.string().min(1).max(100), managedResourceValue)
@@ -98,6 +99,24 @@ export class DiscordGuildResourceDirectory {
98
99
  constructor(client) {
99
100
  this.client = client;
100
101
  }
102
+ listGuildPresence(guildIds) {
103
+ const observedAt = new Date().toISOString();
104
+ const ready = this.client.isReady();
105
+ return Promise.resolve(guildIds.map((guildId) => {
106
+ const guild = this.client.guilds.cache.get(guildId);
107
+ return {
108
+ guildId,
109
+ state: !ready
110
+ ? "unknown"
111
+ : !guild
112
+ ? "not_installed"
113
+ : guild.available
114
+ ? "installed"
115
+ : "unavailable",
116
+ observedAt,
117
+ };
118
+ }));
119
+ }
101
120
  async getGuildResources(guildId) {
102
121
  try {
103
122
  const guild = await this.client.guilds.fetch(guildId);
@@ -108,13 +127,16 @@ export class DiscordGuildResourceDirectory {
108
127
  return {
109
128
  channels: [...channels.values()]
110
129
  .filter((channel) => channel?.type === ChannelType.GuildText ||
111
- channel?.type === ChannelType.GuildAnnouncement)
130
+ channel?.type === ChannelType.GuildAnnouncement ||
131
+ channel?.type === ChannelType.GuildCategory)
112
132
  .map((channel) => ({
113
133
  id: channel.id,
114
134
  name: channel.name,
115
135
  type: channel.type === ChannelType.GuildAnnouncement
116
136
  ? "guild_announcement"
117
- : "guild_text",
137
+ : channel.type === ChannelType.GuildCategory
138
+ ? "guild_category"
139
+ : "guild_text",
118
140
  }))
119
141
  .sort((left, right) => left.name.localeCompare(right.name)),
120
142
  roles: [...roles.values()]
@@ -201,6 +223,8 @@ export class BotControlService {
201
223
  database;
202
224
  resources;
203
225
  supportNotifications;
226
+ presenceOwnership;
227
+ presenceShardCount;
204
228
  #modules;
205
229
  #dashboardModules;
206
230
  #configurations;
@@ -208,10 +232,12 @@ export class BotControlService {
208
232
  #permissions;
209
233
  #services;
210
234
  #privacyProviders;
211
- constructor(database, modules, resources, services, privacyExecutor, supportNotifications) {
235
+ constructor(database, modules, resources, services, privacyExecutor, supportNotifications, presenceOwnership, presenceShardCount) {
212
236
  this.database = database;
213
237
  this.resources = resources;
214
238
  this.supportNotifications = supportNotifications;
239
+ this.presenceOwnership = presenceOwnership;
240
+ this.presenceShardCount = presenceShardCount;
215
241
  this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
216
242
  this.#dashboardModules = modules
217
243
  .filter((module) => module.manifest.dashboard !== undefined)
@@ -229,6 +255,40 @@ export class BotControlService {
229
255
  })
230
256
  : []);
231
257
  }
258
+ async listGuildPresence(input) {
259
+ const guildIds = [...new Set(input.guildIds)];
260
+ if (guildIds.length > 200)
261
+ throw new BotControlValidationError("Guild presence requests cannot exceed 200 guilds");
262
+ const observations = await this.resources.listGuildPresence(guildIds);
263
+ const returned = new Set(observations.map(({ guildId }) => guildId));
264
+ const complete = guildIds.map((guildId) => observations.find((item) => item.guildId === guildId) ?? {
265
+ guildId,
266
+ state: "unknown",
267
+ observedAt: new Date().toISOString(),
268
+ });
269
+ if (observations.some(({ guildId }) => !guildIds.includes(guildId)) ||
270
+ returned.size !== observations.length)
271
+ throw new BotControlTransportError(502, "The guild presence response was invalid");
272
+ const absent = complete
273
+ .filter(({ state }) => state === "not_installed")
274
+ .map(({ guildId }) => guildId);
275
+ if (absent.length > 0) {
276
+ const repository = new InstallationRepository(this.database);
277
+ if (this.presenceOwnership)
278
+ await repository.adoptGuildsForReconciliation({
279
+ guildIds: absent,
280
+ ownership: this.presenceOwnership,
281
+ shardCount: this.presenceShardCount,
282
+ });
283
+ await repository.markGuildsRemovedForReconciliation({
284
+ guildIds: absent,
285
+ ...(this.presenceOwnership
286
+ ? { ownership: this.presenceOwnership }
287
+ : {}),
288
+ });
289
+ }
290
+ return complete;
291
+ }
232
292
  async sendSupportNotification(input) {
233
293
  if (!this.supportNotifications)
234
294
  throw new BotControlNotFoundError("The support notification channel is not configured");
@@ -263,6 +323,15 @@ export class BotControlService {
263
323
  },
264
324
  }, input.notificationId);
265
325
  }
326
+ acceptVerificationResult(envelopeInput) {
327
+ if (!this.#services.has(HELYX_SERVICE_NAMES.verification))
328
+ throw new BotControlTransportError(503, "Verification result handling is unavailable");
329
+ const envelope = signedAssessmentEnvelopeSchema.parse(envelopeInput);
330
+ return this.#services
331
+ .get(HELYX_SERVICE_NAMES.verification)
332
+ .acceptResult(envelope)
333
+ .then((receipt) => actionReceiptSchema.parse(receipt));
334
+ }
266
335
  listPrivacyProviders() {
267
336
  return Promise.resolve([...this.#privacyProviders.values()]
268
337
  .map((provider) => ({
@@ -345,6 +414,19 @@ export class BotControlService {
345
414
  logging,
346
415
  };
347
416
  }
417
+ async canAccessManagedResource(input) {
418
+ try {
419
+ const { module } = await this.#prepareManagedResource(input);
420
+ this.#requireManagedResourceOperation(module, input.resourceType, input.operation);
421
+ await this.#authorizeAction(input.guildId, module, input.actorUserId, managedResourcePermissionId(module, input.resourceType, input.operation));
422
+ return true;
423
+ }
424
+ catch (error) {
425
+ if (error instanceof BotControlForbiddenError)
426
+ return false;
427
+ throw error;
428
+ }
429
+ }
348
430
  async #inspectRuntimeCapabilities() {
349
431
  if (!this.#services.has(HELYX_SERVICE_NAMES.discordResources))
350
432
  return { messageContent: false };
@@ -894,6 +976,7 @@ export async function createBotControlServer(input) {
894
976
  }
895
977
  server.get("/health", (_request, reply) => readiness(reply));
896
978
  server.get("/v1/health", (_request, reply) => readiness(reply));
979
+ server.post("/v1/verification/results", async (request) => input.service.acceptVerificationResult(signedAssessmentEnvelopeSchema.parse(request.body)));
897
980
  server.get("/v1/shards/health", async (request, reply) => {
898
981
  reply.header("Cache-Control", "no-store");
899
982
  if (!input.shardHealth)
@@ -958,6 +1041,18 @@ export async function createBotControlServer(input) {
958
1041
  await input.service.sendSupportNotification(body);
959
1042
  return reply.code(204).send();
960
1043
  });
1044
+ server.post("/v1/guilds/presence", async (request) => {
1045
+ const body = z
1046
+ .object({
1047
+ guildIds: z.array(snowflake).min(1).max(200),
1048
+ correlationId: z.uuid(),
1049
+ })
1050
+ .strict()
1051
+ .parse(request.body);
1052
+ return {
1053
+ items: await input.service.listGuildPresence(body),
1054
+ };
1055
+ });
961
1056
  server.post("/v1/privacy/capture", async (request) => {
962
1057
  const body = z
963
1058
  .object({
@@ -966,11 +1061,13 @@ export async function createBotControlServer(input) {
966
1061
  requestId: z.uuid(),
967
1062
  subjectUserId: snowflake,
968
1063
  cutoffAt: z.coerce.date(),
1064
+ operation: z.enum(["capture", "residual_scan"]).optional(),
969
1065
  cursor: z.string().min(1).max(2_048).optional(),
970
1066
  })
971
1067
  .strict()
972
1068
  .parse(request.body);
973
- const { cursor, ...value } = body;
1069
+ const { cursor, operation: _operation, ...value } = body;
1070
+ void _operation;
974
1071
  return input.service.capturePrivacy({
975
1072
  ...value,
976
1073
  ...(cursor ? { cursor } : {}),
@@ -998,6 +1095,28 @@ export async function createBotControlServer(input) {
998
1095
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
999
1096
  return input.service.getGuildState(guildId);
1000
1097
  });
1098
+ server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/access", async (request) => {
1099
+ const params = z
1100
+ .object({
1101
+ guildId: snowflake,
1102
+ moduleId,
1103
+ resourceType: z.string().regex(/^[a-z][a-z0-9-]*$/u),
1104
+ })
1105
+ .parse(request.params);
1106
+ const body = z
1107
+ .object({
1108
+ operation: z.enum(["list", "read"]),
1109
+ actorUserId: snowflake,
1110
+ })
1111
+ .strict()
1112
+ .parse(request.body);
1113
+ return {
1114
+ allowed: await input.service.canAccessManagedResource({
1115
+ ...params,
1116
+ ...body,
1117
+ }),
1118
+ };
1119
+ });
1001
1120
  server.put("/v1/guilds/:guildId/modules/:moduleId/status", async (request) => {
1002
1121
  const params = z
1003
1122
  .object({ guildId: snowflake, moduleId })
package/dist/events.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
1
+ import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess, VerificationMemberService, VerificationSetupGuildInspection, VerificationSetupPreview, VerificationSetupRequest, VerificationSetupService } from "@helyx/sdk";
2
+ import type { VerificationSetupRepository } from "@helyx/database";
2
3
  import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
3
4
  import { type InteractionLogger } from "./interactions.js";
4
5
  export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): ModuleEventHandle;
@@ -26,7 +27,34 @@ export declare class DiscordMemberEnforcementService implements MemberEnforcemen
26
27
  private readonly client;
27
28
  constructor(client: Client);
28
29
  kick(input: Parameters<MemberEnforcementService["kick"]>[0]): ReturnType<MemberEnforcementService["kick"]>;
30
+ ban(input: Parameters<MemberEnforcementService["ban"]>[0]): ReturnType<MemberEnforcementService["ban"]>;
29
31
  }
32
+ export declare class DiscordVerificationMemberService implements VerificationMemberService {
33
+ private readonly client;
34
+ constructor(client: Client);
35
+ captureSnapshot(input: Parameters<VerificationMemberService["captureSnapshot"]>[0]): ReturnType<VerificationMemberService["captureSnapshot"]>;
36
+ isCurrentlyBanned(input: Parameters<VerificationMemberService["isCurrentlyBanned"]>[0]): ReturnType<VerificationMemberService["isCurrentlyBanned"]>;
37
+ }
38
+ /**
39
+ * The single Discord mutation boundary for Verification setup. It deliberately
40
+ * exposes a preview/apply protocol rather than raw role or overwrite writes.
41
+ */
42
+ export declare class DiscordVerificationSetupService implements VerificationSetupService {
43
+ #private;
44
+ private readonly client;
45
+ private readonly persistence;
46
+ constructor(client: Client, persistence: VerificationSetupPersistence);
47
+ inspect(input: {
48
+ guildId: string;
49
+ }): Promise<VerificationSetupGuildInspection>;
50
+ preview(input: VerificationSetupRequest): Promise<VerificationSetupPreview>;
51
+ apply(input: VerificationSetupRequest & {
52
+ previewFingerprint: string;
53
+ confirmed: true;
54
+ }): Promise<Awaited<ReturnType<VerificationSetupService["apply"]>>>;
55
+ hideVerificationChannelForVerifiedMember(input: Parameters<VerificationSetupService["hideVerificationChannelForVerifiedMember"]>[0]): ReturnType<VerificationSetupService["hideVerificationChannelForVerifiedMember"]>;
56
+ }
57
+ export type VerificationSetupPersistence = Pick<VerificationSetupRepository, "begin" | "checkpointResources" | "checkpointOverwrites" | "complete" | "abort">;
30
58
  export declare class DiscordMemberRoleService implements MemberRoleService {
31
59
  private readonly client;
32
60
  constructor(client: Client);