@helyx/bot 0.4.33 → 0.4.35

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.35
4
+
5
+ ### Patch Changes
6
+
7
+ - 26b4c3e: Add versioned hidden module dependencies, automatic installation lifecycle,
8
+ delegated customer health, and transitive self-host build composition for the
9
+ Nexus foundation while preserving legacy manifest and catalogue behavior. Add
10
+ closed, owner-aware composition for the shared `/nexus` command namespace and
11
+ bounded Core reads for terminology, member history and confirmed member value
12
+ changes. Add resumable, idempotent role and server bulk-award jobs.
13
+ Add bounded Nexus privacy capture, a durable hashed erasure fence and atomic
14
+ member detachment that journals any remaining balance as a privacy burn before
15
+ removing account and bulk-recipient identifiers.
16
+ Generalise the durable Achievement contribution bus as member contributions
17
+ while retaining the existing service names as compatibility aliases.
18
+ Add the private Nexus Rewards Testing package, complete initial record schema
19
+ and an atomic timezone-aware Daily Claim path backed by the shared ledger.
20
+ Add reviewed, default-off Nexus eligibility metadata to existing contribution
21
+ catalogues and atomically apply configured fixed rewards with source, role,
22
+ rate-cap, replay, balance-cap and privacy safeguards.
23
+ Add definition-owned Achievement reward amounts and live repeatable-award
24
+ high-water processing so retries credit only newly committed occurrences.
25
+ Add the private Nexus Economy Testing package and its complete initial Exchange,
26
+ item, redemption, inventory, role-grant and durable fulfilment record schema.
27
+ Add a typed atomic Economy redemption path with exact replay, finite stock,
28
+ member limits, role eligibility, collectible inventory and durable fulfilment.
29
+ Add coalesced leased role fulfilment with reviewed Discord safety, observed
30
+ delivery reconciliation, bounded retries and Core-linked automatic refunds.
31
+ - Updated dependencies [26b4c3e]
32
+ - @helyx/module-manifest@0.7.0
33
+ - @helyx/platform@0.6.0
34
+ - @helyx/sdk@0.8.0
35
+
36
+ ## 0.4.34
37
+
38
+ ### Patch Changes
39
+
40
+ - Add fail-open production status heartbeat configuration and reporting support.
41
+
3
42
  ## 0.4.33
4
43
 
5
44
  ### Patch Changes
@@ -106,6 +106,7 @@ export function createCloudManagementModule(input) {
106
106
  {
107
107
  name: "connect",
108
108
  description: "Connect this deployment using a one-use code",
109
+ acknowledgement: "immediate-modal",
109
110
  rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
110
111
  async execute(context) {
111
112
  if (!(await requireOwner(context)))
@@ -136,6 +137,7 @@ export function createCloudManagementModule(input) {
136
137
  {
137
138
  name: "status",
138
139
  description: "Show this deployment's Cloud Connect status",
140
+ acknowledgement: "deferred-ephemeral",
139
141
  rateLimit: { scope: "user", limit: 20, windowSeconds: 60 },
140
142
  async execute(context) {
141
143
  if (!(await requireOwner(context)))
@@ -146,6 +148,7 @@ export function createCloudManagementModule(input) {
146
148
  {
147
149
  name: "disconnect",
148
150
  description: "Revoke this deployment's Cloud Connect identity",
151
+ acknowledgement: "immediate-modal",
149
152
  rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
150
153
  async execute(context) {
151
154
  if (!(await requireOwner(context)))
@@ -185,6 +188,7 @@ export function createCloudManagementModule(input) {
185
188
  {
186
189
  name: "rotate",
187
190
  description: "Replace the saved Cloud Connect credential",
191
+ acknowledgement: "immediate-modal",
188
192
  rateLimit: { scope: "user", limit: 3, windowSeconds: 60 },
189
193
  async execute(context) {
190
194
  if (!(await requireOwner(context)))
@@ -224,6 +228,7 @@ export function createCloudManagementModule(input) {
224
228
  {
225
229
  name: "support",
226
230
  description: "Choose what Helyx support may inspect",
231
+ acknowledgement: "immediate-modal",
227
232
  rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
228
233
  async execute(context) {
229
234
  if (!(await requireOwner(context)))
@@ -4,13 +4,9 @@ import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verifi
4
4
  import { type Client } from "discord.js";
5
5
  import { type FastifyInstance } from "fastify";
6
6
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
7
- import { type DashboardPermissionPolicy, type DashboardPermissionState } from "./control-permissions.js";
8
- interface DashboardCommandDefinition {
9
- id: string;
10
- name: string;
11
- description: string;
12
- defaultPreset: DashboardPermissionPolicy["preset"];
13
- }
7
+ import { type DashboardPermissionState } from "./control-permissions.js";
8
+ import { type DashboardCommandDefinition } from "./dashboard-commands.js";
9
+ import { type AvailabilityModuleCatalogue } from "./module-availability-control.js";
14
10
  interface DashboardLogEventDefinition {
15
11
  id: string;
16
12
  name: string;
@@ -127,6 +123,7 @@ export interface BotControlState {
127
123
  roles: BotControlGuildResources["roles"];
128
124
  modules: DashboardModuleDefinition[];
129
125
  moduleStatuses: Record<string, "enabled" | "disabled" | "unavailable">;
126
+ globallyDisabledModuleIds: string[];
130
127
  configurations: Record<string, {
131
128
  version: number;
132
129
  value: Record<string, unknown>;
@@ -155,6 +152,7 @@ export interface BotControlHandler {
155
152
  correlationId: string;
156
153
  }): Promise<OAuthConsumerCleanupResult>;
157
154
  sendSupportNotification(input: SupportNotificationInput): Promise<void>;
155
+ listAvailabilityModules(): Promise<AvailabilityModuleCatalogue>;
158
156
  listGuildPresence(input: {
159
157
  guildIds: readonly string[];
160
158
  correlationId: string;
@@ -541,6 +539,7 @@ export declare class BotControlService implements BotControlHandler {
541
539
  correlationId: string;
542
540
  actorAuthenticatedAt?: string;
543
541
  }): Promise<ManagedResourceDetail | null>;
542
+ listAvailabilityModules(): Promise<AvailabilityModuleCatalogue>;
544
543
  }
545
544
  export declare function createBotControlServer(input: {
546
545
  service: BotControlHandler;
@@ -9,7 +9,9 @@ import { z, ZodError } from "zod";
9
9
  import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteChannelMessage as deleteDiscordChannelMessage, editChannelMessage as editDiscordChannelMessage, normalizeDiscordPermission, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
10
10
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
11
11
  import { dashboardCategoryLabel } from "./dashboard-category.js";
12
- import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePermission, emptyPolicy, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
12
+ import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
13
+ import { dashboardCommands, } from "./dashboard-commands.js";
14
+ import { availabilityCatalogueDefinitions, availabilityCatalogue, dashboardModuleStatus, disabledModuleSummary, moduleAvailabilityService, moduleAvailabilityStates, } from "./module-availability-control.js";
13
15
  const snowflake = z.string().regex(/^\d{17,20}$/u);
14
16
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
15
17
  const MAX_DISCORD_SETTING_SELECTIONS = 25;
@@ -258,6 +260,7 @@ export class BotControlService {
258
260
  presenceShardCount;
259
261
  #modules;
260
262
  #dashboardModules;
263
+ #availabilityModules;
261
264
  #configurations;
262
265
  #installations;
263
266
  #permissions;
@@ -273,6 +276,7 @@ export class BotControlService {
273
276
  this.#dashboardModules = modules
274
277
  .filter((module) => module.manifest.dashboard !== undefined)
275
278
  .map(mapDashboardModule);
279
+ this.#availabilityModules = availabilityCatalogueDefinitions(modules);
276
280
  this.#configurations = services.get(HELYX_SERVICE_NAMES.configuration);
277
281
  this.#installations = services.get(HELYX_SERVICE_NAMES.installations);
278
282
  this.#permissions = services.get(HELYX_SERVICE_NAMES.permissions);
@@ -423,14 +427,16 @@ export class BotControlService {
423
427
  commandId: id,
424
428
  })),
425
429
  ]);
426
- const [resources, installedModules, storedLoggingPolicies, runtime, storedConfigurations, storedPermissions,] = await Promise.all([
430
+ const [resources, installedModules, storedLoggingPolicies, runtime, storedConfigurations, storedPermissions, availabilityStates,] = await Promise.all([
427
431
  this.resources.getGuildResources(guildId),
428
432
  new InstallationRepository(this.database).listModules(guildId),
429
433
  new ModuleLoggingPolicyRepository(this.database).listForGuild(guildId),
430
434
  this.#inspectRuntimeCapabilities(),
431
435
  this.#readConfigurations(guildId, moduleIds),
432
436
  this.#readPermissionPolicies(guildId, permissionTargets),
437
+ moduleAvailabilityStates(this.#services, moduleIds),
433
438
  ]);
439
+ const disabledModules = disabledModuleSummary(availabilityStates);
434
440
  const installed = new Map(installedModules.map((module) => [module.moduleId, module]));
435
441
  const moduleStatuses = {};
436
442
  const configurations = {};
@@ -442,11 +448,7 @@ export class BotControlService {
442
448
  ]));
443
449
  for (const { dashboardModule, module } of dashboardEntries) {
444
450
  const installation = installed.get(dashboardModule.id);
445
- moduleStatuses[dashboardModule.id] = installation
446
- ? installation.enabled
447
- ? "enabled"
448
- : "disabled"
449
- : "unavailable";
451
+ moduleStatuses[dashboardModule.id] = dashboardModuleStatus(disabledModules.set, dashboardModule.id, installation);
450
452
  const configuration = storedConfigurations.get(dashboardModule.id);
451
453
  if (configuration)
452
454
  configurations[dashboardModule.id] = {
@@ -470,6 +472,7 @@ export class BotControlService {
470
472
  ...resources,
471
473
  modules: structuredClone(this.#dashboardModules),
472
474
  moduleStatuses,
475
+ globallyDisabledModuleIds: disabledModules.ids,
473
476
  configurations,
474
477
  permissions,
475
478
  logging,
@@ -624,6 +627,7 @@ export class BotControlService {
624
627
  }
625
628
  async setModuleStatus(input) {
626
629
  const module = this.#requireDashboardModule(input.moduleId);
630
+ await this.#requireModuleAvailable(input.moduleId);
627
631
  const scopes = Object.entries(module.records ?? {})
628
632
  .filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
629
633
  .map(([collection]) => ({
@@ -677,6 +681,7 @@ export class BotControlService {
677
681
  }
678
682
  async updateConfiguration(input) {
679
683
  const module = this.#requireDashboardModule(input.moduleId);
684
+ await this.#requireModuleAvailable(input.moduleId);
680
685
  await this.#validateDiscordSettings(input.guildId, module, input.value);
681
686
  if (module.configuration?.validateUpdate) {
682
687
  try {
@@ -710,6 +715,7 @@ export class BotControlService {
710
715
  }
711
716
  async updatePermissions(input) {
712
717
  const module = this.#requireDashboardModule(input.moduleId);
718
+ await this.#requireModuleAvailable(input.moduleId);
713
719
  const commands = dashboardCommands(module);
714
720
  const supplied = new Map(input.state.commands.map((command) => [command.commandId, command]));
715
721
  if (supplied.size !== input.state.commands.length ||
@@ -833,6 +839,10 @@ export class BotControlService {
833
839
  return this.#getPermissionState(input.guildId, module);
834
840
  }
835
841
  async updateLoggingPolicy(input) {
842
+ await Promise.all([
843
+ this.#requireModuleAvailable(input.moduleId),
844
+ this.#requireModuleAvailable("helyx.logging"),
845
+ ]);
836
846
  if (!this.#modules.has("helyx.logging")) {
837
847
  throw new BotControlNotFoundError("Logging is not installed");
838
848
  }
@@ -877,6 +887,7 @@ export class BotControlService {
877
887
  }
878
888
  async executeDashboardAction(input) {
879
889
  const module = this.#requireDashboardModule(input.moduleId);
890
+ await this.#requireModuleAvailable(input.moduleId);
880
891
  const declared = module.manifest.dashboard?.actions?.find(({ id }) => id === input.actionId);
881
892
  const action = module.dashboardActions?.find(({ id }) => id === input.actionId);
882
893
  if (!declared || !action) {
@@ -935,11 +946,13 @@ export class BotControlService {
935
946
  })), relation.columns.map(({ key }) => key));
936
947
  }
937
948
  async previewManagedResource(input) {
949
+ await this.#requireModuleAvailable(input.moduleId);
938
950
  const { module, resource } = this.#prepareManagedResource(input);
939
951
  this.#requireManagedResourceOperation(module, input.resourceType, "preview");
940
952
  return resource.preview(await this.#managedResourceContext(input, module, "preview"), input.value);
941
953
  }
942
954
  async createManagedResource(input) {
955
+ await this.#requireModuleAvailable(input.moduleId);
943
956
  const { module, resource } = this.#prepareManagedResource(input);
944
957
  this.#requireManagedResourceOperation(module, input.resourceType, "create");
945
958
  const context = await this.#managedResourceContext(input, module, "create");
@@ -949,6 +962,7 @@ export class BotControlService {
949
962
  return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.create(context, input.value, input.intent)), managedResourceColumnKeys(module, input.resourceType));
950
963
  }
951
964
  async updateManagedResource(input) {
965
+ await this.#requireModuleAvailable(input.moduleId);
952
966
  const { module, resource } = this.#prepareManagedResource(input);
953
967
  this.#requireManagedResourceOperation(module, input.resourceType, "update");
954
968
  return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.update(await this.#managedResourceContext(input, module, "update"), {
@@ -958,6 +972,7 @@ export class BotControlService {
958
972
  })), managedResourceColumnKeys(module, input.resourceType));
959
973
  }
960
974
  async deleteManagedResource(input) {
975
+ await this.#requireModuleAvailable(input.moduleId);
961
976
  const { module, resource } = this.#prepareManagedResource(input);
962
977
  this.#requireManagedResourceOperation(module, input.resourceType, "delete");
963
978
  return managedResourceDeleteResultSchema.parse(await resource.delete(await this.#managedResourceContext(input, module, "delete"), {
@@ -966,6 +981,7 @@ export class BotControlService {
966
981
  }));
967
982
  }
968
983
  async executeManagedResourceAction(input) {
984
+ await this.#requireModuleAvailable(input.moduleId);
969
985
  const { module, resource } = this.#prepareManagedResource(input);
970
986
  const declared = module.manifest.dashboard?.resources
971
987
  ?.find(({ id }) => id === input.resourceType)
@@ -1061,6 +1077,14 @@ export class BotControlService {
1061
1077
  throw new BotControlNotFoundError("Module is not installed");
1062
1078
  return module;
1063
1079
  }
1080
+ listAvailabilityModules() {
1081
+ return availabilityCatalogue(this.#availabilityModules);
1082
+ }
1083
+ async #requireModuleAvailable(moduleIdValue) {
1084
+ const availability = moduleAvailabilityService(this.#services);
1085
+ if (availability && !(await availability.isAvailable(moduleIdValue)))
1086
+ throw new BotControlValidationError("This module is temporarily unavailable for all hosted servers.");
1087
+ }
1064
1088
  #requireDashboardModule(moduleIdValue) {
1065
1089
  const module = this.#requireModule(moduleIdValue);
1066
1090
  if (!module.manifest.dashboard)
@@ -1368,6 +1392,7 @@ export async function createBotControlServer(input) {
1368
1392
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1369
1393
  return input.service.getGuildState(guildId);
1370
1394
  });
1395
+ server.get("/v1/modules/availability-catalogue", () => input.service.listAvailabilityModules());
1371
1396
  server.post("/v1/guilds/:guildId/dashboard-access", async (request) => {
1372
1397
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1373
1398
  const { actorUserId } = z
@@ -1897,35 +1922,6 @@ function mapDashboardModule(module) {
1897
1922
  : undefined,
1898
1923
  };
1899
1924
  }
1900
- function dashboardCommands(module) {
1901
- const commands = (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
1902
- ? command.subcommands.map((subcommand) => {
1903
- const id = subcommand.permissionId ??
1904
- command.permissionId ??
1905
- `${command.name}.${subcommand.name}`;
1906
- return {
1907
- id,
1908
- name: `/${command.name} ${subcommand.name}`,
1909
- description: subcommand.description,
1910
- defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[id] ??
1911
- module.defaultPermissions?.module ??
1912
- emptyPolicy()).preset,
1913
- };
1914
- })
1915
- : [
1916
- {
1917
- id: command.permissionId ?? command.name,
1918
- name: `/${command.name}`,
1919
- description: command.description,
1920
- defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[command.name] ??
1921
- module.defaultPermissions?.module ??
1922
- emptyPolicy()).preset,
1923
- },
1924
- ]);
1925
- return [
1926
- ...new Map(commands.map((command) => [command.id, command])).values(),
1927
- ];
1928
- }
1929
1925
  function assertPermissionMutationAllowed(decision) {
1930
1926
  if (decision.allowed)
1931
1927
  return;
@@ -0,0 +1,10 @@
1
+ import type { ModuleDefinition } from "@helyx/sdk";
2
+ import { type DashboardPermissionPolicy } from "./control-permissions.js";
3
+ export interface DashboardCommandDefinition {
4
+ id: string;
5
+ name: string;
6
+ description: string;
7
+ defaultPreset: DashboardPermissionPolicy["preset"];
8
+ }
9
+ export declare function dashboardCommands(module: ModuleDefinition): DashboardCommandDefinition[];
10
+ //# sourceMappingURL=dashboard-commands.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { emptyPolicy, toDashboardPolicy, } from "./control-permissions.js";
2
+ export function dashboardCommands(module) {
3
+ const commands = (module.interactions?.commands ?? []).flatMap((command) => {
4
+ const nested = [
5
+ ...(command.subcommands ?? []).map((subcommand) => dashboardCommand(module, {
6
+ id: subcommand.permissionId ??
7
+ command.permissionId ??
8
+ `${command.name}.${subcommand.name}`,
9
+ name: `/${command.name} ${subcommand.name}`,
10
+ description: subcommand.description,
11
+ })),
12
+ ...(command.subcommandGroups ?? []).flatMap((group) => group.subcommands.map((subcommand) => dashboardCommand(module, {
13
+ id: subcommand.permissionId ??
14
+ command.permissionId ??
15
+ `${command.name}.${group.name}.${subcommand.name}`,
16
+ name: `/${command.name} ${group.name} ${subcommand.name}`,
17
+ description: subcommand.description,
18
+ }))),
19
+ ];
20
+ if (nested.length > 0)
21
+ return nested;
22
+ return [
23
+ dashboardCommand(module, {
24
+ id: command.permissionId ?? command.name,
25
+ name: `/${command.name}`,
26
+ description: command.description,
27
+ }),
28
+ ];
29
+ });
30
+ commands.push(...(module.nexusCommands ?? []).map((command) => dashboardCommand(module, {
31
+ id: `nexus.${command.path}`,
32
+ name: `/nexus ${command.path.replace(".", " ")}`,
33
+ description: command.description,
34
+ })));
35
+ return [
36
+ ...new Map(commands.map((command) => [command.id, command])).values(),
37
+ ];
38
+ }
39
+ function dashboardCommand(module, command) {
40
+ return {
41
+ ...command,
42
+ defaultPreset: toDashboardPolicy(module.defaultPermissions?.commands[command.id] ??
43
+ module.defaultPermissions?.module ??
44
+ emptyPolicy()).preset,
45
+ };
46
+ }
47
+ //# sourceMappingURL=dashboard-commands.js.map
@@ -33,6 +33,7 @@ export function createDeveloperOperationsModule(input) {
33
33
  {
34
34
  name: "shards",
35
35
  description: "Show bounded read-only shard health",
36
+ acknowledgement: "deferred-ephemeral",
36
37
  rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
37
38
  async execute(context) {
38
39
  if (!(await authorizeDeveloperCommand(context)))
@@ -81,6 +82,7 @@ export function createDeveloperOperationsModule(input) {
81
82
  {
82
83
  name: "bot",
83
84
  description: "Show the bot build and installed module versions",
85
+ acknowledgement: "deferred-ephemeral",
84
86
  rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
85
87
  async execute(context) {
86
88
  if (!(await authorizeDeveloperCommand(context)))
@@ -134,6 +136,7 @@ export function createDeveloperOperationsModule(input) {
134
136
  {
135
137
  name: "proc",
136
138
  description: "Show safe process resource and uptime information",
139
+ acknowledgement: "deferred-ephemeral",
137
140
  rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
138
141
  async execute(context) {
139
142
  if (!(await authorizeDeveloperCommand(context)))
@@ -1,9 +1,10 @@
1
+ import { type ModuleManifest } from "@helyx/module-manifest";
1
2
  import type { DiscordGuildHealthInspectionService, DiscordGuildHealthUnavailable, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
2
3
  import { type Client } from "discord.js";
3
4
  export declare function moduleCatalogueMatches(packagedManifest: unknown, executableManifest: unknown): boolean;
4
5
  export declare function missingRequiredPermissions(grantedPermissions: readonly string[], requiredPermissions: readonly string[]): string[];
5
6
  export declare function hasRequiredModuleConfiguration(exposesConfiguration: boolean, savedConfiguration: unknown): boolean;
6
- export declare function isHealthInspectableModule(moduleId: string): boolean;
7
+ export declare function isHealthInspectableModule(module: ModuleManifest | string): boolean;
7
8
  export declare class DiscordGuildHealthInspection implements DiscordGuildHealthInspectionService {
8
9
  #private;
9
10
  private readonly client;
@@ -1,3 +1,4 @@
1
+ import { hasDirectModuleHealthExposure, } from "@helyx/module-manifest";
1
2
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
3
  import { AuditLogEvent, GatewayIntentBits, } from "discord.js";
3
4
  import { isDeepStrictEqual } from "node:util";
@@ -21,8 +22,10 @@ export function missingRequiredPermissions(grantedPermissions, requiredPermissio
21
22
  export function hasRequiredModuleConfiguration(exposesConfiguration, savedConfiguration) {
22
23
  return !exposesConfiguration || savedConfiguration !== null;
23
24
  }
24
- export function isHealthInspectableModule(moduleId) {
25
- return !INTERNAL_HELYX_MODULE_IDS.has(moduleId);
25
+ export function isHealthInspectableModule(module) {
26
+ const moduleId = typeof module === "string" ? module : module.id;
27
+ return (!INTERNAL_HELYX_MODULE_IDS.has(moduleId) &&
28
+ (typeof module === "string" || hasDirectModuleHealthExposure(module)));
26
29
  }
27
30
  export class DiscordGuildHealthInspection {
28
31
  client;
@@ -270,7 +273,7 @@ export class DiscordGuildHealthInspection {
270
273
  return overview;
271
274
  const items = [];
272
275
  const batchSize = Math.min(20, Math.max(1, input.batchSize ?? 20));
273
- const inspectableModules = this.modules.filter((module) => isHealthInspectableModule(module.manifest.id));
276
+ const inspectableModules = this.modules.filter((module) => isHealthInspectableModule(module.manifest));
274
277
  for (let index = 0; index < inspectableModules.length; index += 1) {
275
278
  const module = inspectableModules[index];
276
279
  const state = await installations.getModuleState(input.guildId, module.manifest.id);
package/dist/events.js CHANGED
@@ -3,6 +3,20 @@ import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, PermissionFla
3
3
  import { toReplyOptions } from "./interactions.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
6
+ function availabilityAwareInstallations(services) {
7
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
8
+ const candidate = typeof services.has === "function" &&
9
+ services.has(HELYX_SERVICE_NAMES.moduleAvailability)
10
+ ? services.get(HELYX_SERVICE_NAMES.moduleAvailability)
11
+ : null;
12
+ const availability = typeof candidate?.isAvailable === "function" ? candidate : null;
13
+ return {
14
+ async isModuleEnabled(guildId, moduleId) {
15
+ return ((!availability || (await availability.isAvailable(moduleId))) &&
16
+ installations.isModuleEnabled(guildId, moduleId));
17
+ },
18
+ };
19
+ }
6
20
  export function installModuleEvents(client, modules, logger, services) {
7
21
  const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
8
22
  moduleId: module.manifest.id,
@@ -113,7 +127,7 @@ export function installModuleEvents(client, modules, logger, services) {
113
127
  if (!message.guildId || !privilegedEligibilityQueue)
114
128
  return;
115
129
  privilegedEligibilityQueue.enqueue(message.guildId, async () => {
116
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
130
+ const installations = availabilityAwareInstallations(services);
117
131
  const eligible = [];
118
132
  for (const item of privilegedMessageContributions) {
119
133
  try {
@@ -260,7 +274,7 @@ export function installModuleEvents(client, modules, logger, services) {
260
274
  });
261
275
  }
262
276
  async function handleResourceLifecycle(contributions, serverId, identifier, resourceId, eventName) {
263
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
277
+ const installations = availabilityAwareInstallations(services);
264
278
  for (const { moduleId, contribution } of contributions) {
265
279
  try {
266
280
  if (!(await installations.isModuleEnabled(serverId, moduleId))) {
@@ -316,7 +330,7 @@ export function installModuleEvents(client, modules, logger, services) {
316
330
  async function handleGuildMessageCreate(message) {
317
331
  if (!message.guildId || !message.channelId)
318
332
  return;
319
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
333
+ const installations = availabilityAwareInstallations(services);
320
334
  const parentChannelId = message.channel.isThread()
321
335
  ? (message.channel.parentId ?? undefined)
322
336
  : undefined;
@@ -410,7 +424,7 @@ export function installModuleEvents(client, modules, logger, services) {
410
424
  : reaction.message;
411
425
  if (!message.guildId || !message.channelId)
412
426
  return;
413
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
427
+ const installations = availabilityAwareInstallations(services);
414
428
  const member = await message.guild?.members.fetch(user.id);
415
429
  const userRoleIds = member ? [...member.roles.cache.keys()] : [];
416
430
  const parentChannelId = messageParentChannelId(message);
@@ -467,7 +481,7 @@ export function installModuleEvents(client, modules, logger, services) {
467
481
  async function handleMemberAdd(member) {
468
482
  if (!member.joinedAt)
469
483
  throw new Error("Discord member join timestamp is unavailable");
470
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
484
+ const installations = availabilityAwareInstallations(services);
471
485
  for (const { moduleId, contribution } of memberAddContributions) {
472
486
  try {
473
487
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
@@ -498,7 +512,7 @@ export function installModuleEvents(client, modules, logger, services) {
498
512
  }
499
513
  }
500
514
  async function handleMemberRemove(member) {
501
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
515
+ const installations = availabilityAwareInstallations(services);
502
516
  for (const { moduleId, contribution } of memberRemoveContributions) {
503
517
  try {
504
518
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
@@ -536,7 +550,7 @@ export function installModuleEvents(client, modules, logger, services) {
536
550
  : reaction.message;
537
551
  if (!message.guildId || !message.channelId)
538
552
  return;
539
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
553
+ const installations = availabilityAwareInstallations(services);
540
554
  const parentChannelId = messageParentChannelId(message);
541
555
  for (const { moduleId, contribution } of reactionRemoveContributions) {
542
556
  if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
@@ -580,7 +594,7 @@ export function installModuleEvents(client, modules, logger, services) {
580
594
  : receivedMessage;
581
595
  if (!message.guildId || !message.channelId)
582
596
  return;
583
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
597
+ const installations = availabilityAwareInstallations(services);
584
598
  for (const { moduleId, contribution } of reactionRemoveAllContributions) {
585
599
  try {
586
600
  if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
@@ -610,7 +624,7 @@ export function installModuleEvents(client, modules, logger, services) {
610
624
  const message = reaction.message;
611
625
  if (!message.guildId || !message.channelId)
612
626
  return;
613
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
627
+ const installations = availabilityAwareInstallations(services);
614
628
  for (const { moduleId, contribution } of reactionRemoveEmojiContributions) {
615
629
  try {
616
630
  if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
@@ -638,7 +652,7 @@ export function installModuleEvents(client, modules, logger, services) {
638
652
  async function handleMessageUpdate(receivedMessage) {
639
653
  if (!receivedMessage.guildId || !receivedMessage.channelId)
640
654
  return;
641
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
655
+ const installations = availabilityAwareInstallations(services);
642
656
  for (const { moduleId, contribution } of messageUpdateContributions) {
643
657
  try {
644
658
  if (!(await installations.isModuleEnabled(receivedMessage.guildId, moduleId)))
@@ -672,7 +686,7 @@ export function installModuleEvents(client, modules, logger, services) {
672
686
  async function handleMessageDelete(message) {
673
687
  if (!message.guildId || !message.channelId)
674
688
  return;
675
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
689
+ const installations = availabilityAwareInstallations(services);
676
690
  for (const { moduleId, contribution } of messageDeleteContributions) {
677
691
  try {
678
692
  if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
@@ -699,7 +713,7 @@ export function installModuleEvents(client, modules, logger, services) {
699
713
  async function handleMessageDeleteBulk(guildId, channelId, messageIds) {
700
714
  if (messageIds.length === 0)
701
715
  return;
702
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
716
+ const installations = availabilityAwareInstallations(services);
703
717
  const boundedIds = messageIds.slice(0, 100);
704
718
  for (const { moduleId, contribution } of messageDeleteBulkContributions) {
705
719
  try {
@@ -733,7 +747,7 @@ export function installModuleEvents(client, modules, logger, services) {
733
747
  voiceSessions.delete(key);
734
748
  const endedAt = new Date();
735
749
  const durationSeconds = Math.max(1, Math.floor((endedAt.getTime() - previous.startedAt.getTime()) / 1_000));
736
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
750
+ const installations = availabilityAwareInstallations(services);
737
751
  for (const { moduleId, contribution } of guildVoiceSessionContributions) {
738
752
  try {
739
753
  if (!(await installations.isModuleEnabled(previous.guildId, moduleId)))
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
+ import { StatusHeartbeatReporter } from "@helyx/status-client";
2
3
  import { safeError, ServiceRegistry } from "@helyx/core";
3
4
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
5
  import { createRuntime } from "@helyx/framework";
@@ -44,9 +45,45 @@ export async function startBot(processEnvironment = process.env) {
44
45
  return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
45
46
  const environment = parseBotEnvironment(processEnvironment);
46
47
  bootstrapLogger.level = environment.LOG_LEVEL;
47
- if (environment.HELYX_SHARD_MODE !== "off")
48
- return startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint());
49
- return startUnshardedBot(processEnvironment);
48
+ const handle = environment.HELYX_SHARD_MODE !== "off"
49
+ ? await startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint())
50
+ : await startUnshardedBot(processEnvironment);
51
+ if (!environment.HELYX_STATUS_ENABLED)
52
+ return handle;
53
+ const processStartedAt = new Date().toISOString();
54
+ const reporter = new StatusHeartbeatReporter({
55
+ producer: "bot",
56
+ baseUrl: environment.HELYX_STATUS_URL,
57
+ secret: environment.HELYX_STATUS_HEARTBEAT_SECRET,
58
+ accessClientId: environment.CF_ACCESS_CLIENT_ID,
59
+ accessClientSecret: environment.CF_ACCESS_CLIENT_SECRET,
60
+ intervalSeconds: environment.HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS,
61
+ services: () => [
62
+ {
63
+ key: "discord-bot",
64
+ state: "operational",
65
+ processStartedAt,
66
+ release: readBotVersion(),
67
+ },
68
+ {
69
+ key: "creator-connections",
70
+ state: environment.HELYX_OAUTH_ENABLED ? "operational" : "major_outage",
71
+ reasonCode: environment.HELYX_OAUTH_ENABLED ? null : "feature_disabled",
72
+ processStartedAt,
73
+ release: readBotVersion(),
74
+ },
75
+ ],
76
+ onError(error) {
77
+ bootstrapLogger.warn({ event: "status.heartbeat_failed", reason: error.message }, "Status heartbeat could not be delivered");
78
+ },
79
+ });
80
+ reporter.start();
81
+ return {
82
+ async shutdown(reason) {
83
+ await reporter.stop();
84
+ await handle.shutdown(reason);
85
+ },
86
+ };
50
87
  }
51
88
  export function resolveBotWorkerEntryPoint(entryUrl = import.meta.url) {
52
89
  return fileURLToPath(entryUrl);
@@ -254,6 +291,9 @@ async function startUnshardedBot(processEnvironment) {
254
291
  })) {
255
292
  platformServices.set(name, service);
256
293
  }
294
+ await serviceAccess
295
+ .get(HELYX_SERVICE_NAMES.moduleAvailability)
296
+ .refresh();
257
297
  if (environment.HELYX_VERIFICATION_ENABLED &&
258
298
  environment.HELYX_VERIFICATION_URL &&
259
299
  environment.HELYX_VERIFICATION_INTERNAL_SECRET) {