@helyx/bot 0.3.1 → 0.4.2

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.
@@ -0,0 +1,28 @@
1
+ import { type ModuleDefinition } from "@helyx/sdk";
2
+ import { type ShardHealthSnapshot } from "./shard-contracts.js";
3
+ export interface ShardHealthProvider {
4
+ getHealth(): Promise<ShardHealthSnapshot>;
5
+ }
6
+ export interface DeveloperModuleSummary {
7
+ id: string;
8
+ name: string;
9
+ version: string;
10
+ }
11
+ export interface DeveloperOperationsInput {
12
+ internalGuildId: string;
13
+ approvedRoleIds: readonly string[];
14
+ botVersion: string;
15
+ deploymentReference?: string;
16
+ coreFunctions: readonly DeveloperModuleSummary[];
17
+ modules: readonly DeveloperModuleSummary[];
18
+ processLabel: string;
19
+ health: ShardHealthProvider;
20
+ }
21
+ export declare function createDeveloperOperationsModule(input: DeveloperOperationsInput): ModuleDefinition;
22
+ export declare class WorkerShardHealthProvider implements ShardHealthProvider {
23
+ #private;
24
+ getHealth(): Promise<ShardHealthSnapshot>;
25
+ accept(raw: unknown): void;
26
+ stop(): void;
27
+ }
28
+ //# sourceMappingURL=developer-operations-module.d.ts.map
@@ -0,0 +1,249 @@
1
+ import { defineModule, } from "@helyx/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ import { SHARD_CONTROL_TIMEOUT_MS, assertShardMessageSize, shardOperationsHealthResponseSchema, } from "./shard-contracts.js";
4
+ const DEVELOPER_MODULE_ID = "helyx.developer-operations";
5
+ const DEVELOPER_MODULE_NAME = "Helyx Developer Operations";
6
+ const DEVELOPER_MODULE_VERSION = "0.2.0";
7
+ const MAX_MODULE_LINES = 8;
8
+ export function createDeveloperOperationsModule(input) {
9
+ const approvedRoles = new Set(input.approvedRoleIds);
10
+ return defineModule({
11
+ manifest: {
12
+ manifestVersion: "0.1",
13
+ releaseStatus: "live",
14
+ id: DEVELOPER_MODULE_ID,
15
+ name: DEVELOPER_MODULE_NAME,
16
+ version: DEVELOPER_MODULE_VERSION,
17
+ description: "Private read-only development operations for Helyx support.",
18
+ entry: "./dist/developer-operations-module.js",
19
+ framework: "^0.1.0",
20
+ dependencies: [],
21
+ permissions: [],
22
+ contributions: { commands: ["dev"], events: [] },
23
+ },
24
+ interactions: {
25
+ commands: [
26
+ {
27
+ name: "dev",
28
+ description: "Inspect private Helyx development operations",
29
+ registration: "guild",
30
+ contexts: ["guild"],
31
+ integrationTypes: ["guild_install"],
32
+ subcommands: [
33
+ {
34
+ name: "shards",
35
+ description: "Show bounded read-only shard health",
36
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
37
+ async execute(context) {
38
+ if (!(await authorizeDeveloperCommand(context)))
39
+ return;
40
+ const health = await input.health.getHealth();
41
+ const shardLines = health.shards.slice(0, 25).map((shard) => {
42
+ const ping = shard.gatewayPingMs < 0
43
+ ? "n/a"
44
+ : `${Math.round(shard.gatewayPingMs)}ms`;
45
+ return `\`${shard.shardId}\` ${shard.status} · ${shard.guildCount} guilds · ${ping} · restarts ${shard.restartCount}`;
46
+ });
47
+ await context.reply({
48
+ ephemeral: true,
49
+ componentsV2: {
50
+ accentColor: health.status === "healthy" ? 0x16a34a : 0xdc2626,
51
+ text: [
52
+ {
53
+ content: [
54
+ "## Shard health",
55
+ `**${health.status}** · ${health.readyShardCount}/${health.configuredShardCount} ready`,
56
+ `Mode: ${health.mode}/${health.scope}`,
57
+ `Generation: \`${health.generation}\``,
58
+ `Observed: ${health.observedAt}`,
59
+ "",
60
+ ...shardLines,
61
+ ...(health.shards.length < health.configuredShardCount
62
+ ? [
63
+ `… showing ${health.shards.length} of ${health.configuredShardCount} shards`,
64
+ ]
65
+ : []),
66
+ ].join("\n"),
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ },
72
+ },
73
+ {
74
+ name: "bot",
75
+ description: "Show the bot build and installed module versions",
76
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
77
+ async execute(context) {
78
+ if (!(await authorizeDeveloperCommand(context)))
79
+ return;
80
+ const coreFunctions = [
81
+ {
82
+ id: DEVELOPER_MODULE_ID,
83
+ name: DEVELOPER_MODULE_NAME,
84
+ version: DEVELOPER_MODULE_VERSION,
85
+ },
86
+ ...input.coreFunctions,
87
+ ].sort((left, right) => left.id.localeCompare(right.id));
88
+ const modules = [...input.modules].sort((left, right) => left.id.localeCompare(right.id));
89
+ const visibleCoreFunctions = coreFunctions.slice(0, MAX_MODULE_LINES);
90
+ const visibleModules = modules.slice(0, Math.max(0, MAX_MODULE_LINES - visibleCoreFunctions.length));
91
+ const coreFunctionLines = visibleCoreFunctions.map((coreFunction) => `- ${safeInlineCode(coreFunction.name)} · ${safeInlineCode(coreFunction.id)} · ${safeInlineCode(coreFunction.version)}`);
92
+ const moduleLines = visibleModules.map((module) => `- ${safeInlineCode(module.name)} · ${safeInlineCode(module.id)} · ${safeInlineCode(module.version)}`);
93
+ const componentCount = coreFunctions.length + modules.length;
94
+ const visibleComponentCount = visibleCoreFunctions.length + visibleModules.length;
95
+ await context.reply({
96
+ ephemeral: true,
97
+ componentsV2: {
98
+ accentColor: 0x2563eb,
99
+ text: [
100
+ {
101
+ content: [
102
+ "## Bot information",
103
+ `Version: ${safeInlineCode(input.botVersion)}`,
104
+ `Build: ${safeInlineCode(input.deploymentReference ?? "not supplied")}`,
105
+ `Runtime: ${input.processLabel}`,
106
+ `Core functions: ${coreFunctions.length}`,
107
+ `Installed modules: ${modules.length}`,
108
+ "",
109
+ "### Core functions",
110
+ ...coreFunctionLines,
111
+ "",
112
+ "### Modules",
113
+ ...moduleLines,
114
+ ...(componentCount > visibleComponentCount
115
+ ? [
116
+ `… showing ${visibleComponentCount} of ${componentCount} components`,
117
+ ]
118
+ : []),
119
+ ].join("\n"),
120
+ },
121
+ ],
122
+ },
123
+ });
124
+ },
125
+ },
126
+ {
127
+ name: "proc",
128
+ description: "Show safe process resource and uptime information",
129
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
130
+ async execute(context) {
131
+ if (!(await authorizeDeveloperCommand(context)))
132
+ return;
133
+ const memory = process.memoryUsage();
134
+ const cpu = process.cpuUsage();
135
+ await context.reply({
136
+ ephemeral: true,
137
+ componentsV2: {
138
+ accentColor: 0x2563eb,
139
+ text: [
140
+ {
141
+ content: [
142
+ "## Process information",
143
+ `Runtime: ${input.processLabel}`,
144
+ `Uptime: ${formatDuration(process.uptime())}`,
145
+ `RSS memory: ${formatBytes(memory.rss)}`,
146
+ `Heap: ${formatBytes(memory.heapUsed)} used / ${formatBytes(memory.heapTotal)} allocated`,
147
+ `External memory: ${formatBytes(memory.external)}`,
148
+ `CPU time: ${formatCpu(cpu.user)} user / ${formatCpu(cpu.system)} system`,
149
+ `Node: ${safeInlineCode(process.version)}`,
150
+ `Platform: ${safeInlineCode(`${process.platform}/${process.arch}`)}`,
151
+ `PID: ${process.pid}`,
152
+ ].join("\n"),
153
+ },
154
+ ],
155
+ },
156
+ });
157
+ },
158
+ },
159
+ ],
160
+ },
161
+ ],
162
+ },
163
+ start: () => Promise.resolve(),
164
+ stop: () => Promise.resolve(),
165
+ });
166
+ async function authorizeDeveloperCommand(context) {
167
+ const approved = context.serverId === input.internalGuildId &&
168
+ (context.isAdministrator ||
169
+ context.userRoleIds.some((roleId) => approvedRoles.has(roleId)));
170
+ if (approved)
171
+ return true;
172
+ await context.reply({
173
+ ephemeral: true,
174
+ componentsV2: {
175
+ accentColor: 0xdc2626,
176
+ text: [
177
+ {
178
+ content: "This private development command is unavailable.",
179
+ },
180
+ ],
181
+ },
182
+ });
183
+ return false;
184
+ }
185
+ }
186
+ function safeInlineCode(value) {
187
+ return `\`${value.replaceAll("`", "'").slice(0, 120)}\``;
188
+ }
189
+ function formatBytes(value) {
190
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
191
+ }
192
+ function formatCpu(microseconds) {
193
+ return `${(microseconds / 1_000_000).toFixed(2)}s`;
194
+ }
195
+ function formatDuration(seconds) {
196
+ const total = Math.max(0, Math.floor(seconds));
197
+ const days = Math.floor(total / 86_400);
198
+ const hours = Math.floor((total % 86_400) / 3_600);
199
+ const minutes = Math.floor((total % 3_600) / 60);
200
+ const remainder = total % 60;
201
+ return [
202
+ ...(days > 0 ? [`${days}d`] : []),
203
+ ...(days > 0 || hours > 0 ? [`${hours}h`] : []),
204
+ `${minutes}m`,
205
+ `${remainder}s`,
206
+ ].join(" ");
207
+ }
208
+ export class WorkerShardHealthProvider {
209
+ #pending = new Map();
210
+ getHealth() {
211
+ if (!process.send)
212
+ return Promise.reject(new Error("Shard supervisor IPC is unavailable"));
213
+ const requestId = randomUUID();
214
+ const message = {
215
+ version: 1,
216
+ type: "helyx.operations.health.request",
217
+ requestId,
218
+ deadlineAt: new Date(Date.now() + SHARD_CONTROL_TIMEOUT_MS).toISOString(),
219
+ };
220
+ assertShardMessageSize(message);
221
+ const response = new Promise((resolve, reject) => {
222
+ const timer = setTimeout(() => {
223
+ this.#pending.delete(requestId);
224
+ reject(new Error("Shard health request timed out"));
225
+ }, SHARD_CONTROL_TIMEOUT_MS);
226
+ timer.unref();
227
+ this.#pending.set(requestId, { resolve, reject, timer });
228
+ });
229
+ process.send(message);
230
+ return response;
231
+ }
232
+ accept(raw) {
233
+ const response = shardOperationsHealthResponseSchema.parse(raw);
234
+ const pending = this.#pending.get(response.requestId);
235
+ if (!pending)
236
+ return;
237
+ this.#pending.delete(response.requestId);
238
+ clearTimeout(pending.timer);
239
+ pending.resolve(response.health);
240
+ }
241
+ stop() {
242
+ for (const pending of this.#pending.values()) {
243
+ clearTimeout(pending.timer);
244
+ pending.reject(new Error("Shard worker is stopping"));
245
+ }
246
+ this.#pending.clear();
247
+ }
248
+ }
249
+ //# sourceMappingURL=developer-operations-module.js.map
package/dist/events.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { 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
- export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): void;
4
+ export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): ModuleEventHandle;
5
+ export interface ModuleEventHandle {
6
+ stop(): Promise<void>;
7
+ queueDepth(): number;
8
+ }
5
9
  export declare function reactionEmojiKey(reaction: MessageReaction | PartialMessageReaction): string;
6
10
  export declare function reactionCacheKey(emojiIdentifier: string): string;
7
11
  //# sourceMappingURL=events.d.ts.map
package/dist/events.js CHANGED
@@ -11,15 +11,18 @@ export function installModuleEvents(client, modules, logger, services) {
11
11
  contribution,
12
12
  })));
13
13
  if (reactionContributions.length === 0 && memberAddContributions.length === 0)
14
- return;
14
+ return { stop: () => Promise.resolve(), queueDepth: () => 0 };
15
+ const queue = new BoundedGuildEventQueue(logger);
15
16
  if (reactionContributions.length > 0) {
16
17
  client.on(Events.MessageReactionAdd, (reaction, user) => {
17
- void handleReaction(reaction, user);
18
+ const guildId = reaction.message.guildId;
19
+ if (guildId)
20
+ queue.enqueue(guildId, () => handleReaction(reaction, user));
18
21
  });
19
22
  }
20
23
  if (memberAddContributions.length > 0) {
21
24
  client.on(Events.GuildMemberAdd, (member) => {
22
- void handleMemberAdd(member);
25
+ queue.enqueue(member.guild.id, () => handleMemberAdd(member));
23
26
  });
24
27
  }
25
28
  async function handleReaction(receivedReaction, receivedUser) {
@@ -109,6 +112,69 @@ export function installModuleEvents(client, modules, logger, services) {
109
112
  }
110
113
  }
111
114
  }
115
+ return { stop: () => queue.stop(), queueDepth: () => queue.queueDepth() };
116
+ }
117
+ class BoundedGuildEventQueue {
118
+ logger;
119
+ #pending = [];
120
+ #activeGuilds = new Set();
121
+ #drainWaiters = new Set();
122
+ #active = 0;
123
+ #accepting = true;
124
+ constructor(logger) {
125
+ this.logger = logger;
126
+ }
127
+ enqueue(guildId, run) {
128
+ if (!this.#accepting || this.#pending.length >= 1_000) {
129
+ this.logger.warn({ event: "module.event_dropped", reason: "queue_capacity" }, "Module gateway event queue rejected work");
130
+ return;
131
+ }
132
+ this.#pending.push({ guildId, run });
133
+ this.#pump();
134
+ }
135
+ queueDepth() {
136
+ return this.#pending.length;
137
+ }
138
+ async stop() {
139
+ this.#accepting = false;
140
+ if (this.#active === 0 && this.#pending.length === 0)
141
+ return;
142
+ await Promise.race([
143
+ new Promise((resolve) => this.#drainWaiters.add(resolve)),
144
+ new Promise((resolve) => {
145
+ const timer = setTimeout(resolve, 5_000);
146
+ timer.unref();
147
+ }),
148
+ ]);
149
+ this.#pending.length = 0;
150
+ }
151
+ #pump() {
152
+ while (this.#active < 16) {
153
+ const index = this.#pending.findIndex(({ guildId }) => !this.#activeGuilds.has(guildId));
154
+ if (index < 0)
155
+ break;
156
+ const [task] = this.#pending.splice(index, 1);
157
+ if (!task)
158
+ break;
159
+ this.#active += 1;
160
+ this.#activeGuilds.add(task.guildId);
161
+ void task
162
+ .run()
163
+ .catch((error) => {
164
+ this.logger.warn({ event: "module.event_failed", error: safeError(error) }, "Queued module gateway event failed");
165
+ })
166
+ .finally(() => {
167
+ this.#active -= 1;
168
+ this.#activeGuilds.delete(task.guildId);
169
+ this.#pump();
170
+ if (this.#active === 0 && this.#pending.length === 0) {
171
+ for (const resolve of this.#drainWaiters)
172
+ resolve();
173
+ this.#drainWaiters.clear();
174
+ }
175
+ });
176
+ }
177
+ }
112
178
  }
113
179
  async function assignMemberRole(member, roleId) {
114
180
  const role = await member.guild.roles.fetch(roleId);
package/dist/guilds.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { type DatabasePool } from "@helyx/database";
1
+ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
2
2
  import type { ModuleDefinition } from "@helyx/sdk";
3
3
  import { type Client } from "discord.js";
4
4
  import type { InteractionLogger } from "./interactions.js";
5
- export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>): (readyClient: Client<true>) => Promise<void>;
5
+ export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership): (readyClient: Client<true>) => Promise<void>;
6
6
  //# sourceMappingURL=guilds.d.ts.map
package/dist/guilds.js CHANGED
@@ -1,13 +1,21 @@
1
- import { InstallationRepository } from "@helyx/database";
1
+ import { InstallationRepository, } from "@helyx/database";
2
2
  import { Events } from "discord.js";
3
- export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set()) {
3
+ export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership) {
4
4
  const repository = new InstallationRepository(database);
5
5
  async function syncGuild(guild) {
6
- await repository.syncGuild({
6
+ const record = await repository.syncGuild({
7
7
  guildId: guild.id,
8
8
  ownerUserId: guild.ownerId,
9
9
  name: guild.name,
10
+ ...(ownership ? { ownership } : {}),
10
11
  });
12
+ if (ownership &&
13
+ (record.shardGeneration !== ownership.generation ||
14
+ record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
15
+ record.shardSupervisorId !== ownership.supervisorId ||
16
+ record.shardId !== ownership.shardId ||
17
+ record.shardFencingToken !== ownership.fencingToken))
18
+ throw new Error("Guild observation was rejected by a newer shard owner");
11
19
  for (const module of modules) {
12
20
  await repository.ensureModule({
13
21
  guildId: guild.id,
@@ -29,7 +37,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
29
37
  });
30
38
  });
31
39
  client.on(Events.GuildDelete, (guild) => {
32
- void repository.markGuildRemoved(guild.id).catch((error) => {
40
+ void repository
41
+ .markGuildRemoved(guild.id, ownership)
42
+ .catch((error) => {
33
43
  logger.error({
34
44
  event: "guild.remove_failed",
35
45
  guildId: guild.id,
@@ -37,6 +47,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
37
47
  }, "Guild removal tracking failed");
38
48
  });
39
49
  });
50
+ client.on(Events.GuildUnavailable, (guild) => {
51
+ logger.warn({ event: "guild.unavailable", guildId: guild.id }, "Discord guild is temporarily unavailable; installation remains active");
52
+ });
40
53
  return async (readyClient) => {
41
54
  for (const guild of readyClient.guilds.cache.values())
42
55
  await syncGuild(guild);
package/dist/index.d.ts CHANGED
@@ -7,5 +7,7 @@ export interface BotHandle {
7
7
  shutdown(reason?: string): Promise<void>;
8
8
  }
9
9
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
10
+ export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
11
+ export declare function readBotVersion(): string;
10
12
  export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[]): GatewayIntentBits[];
11
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -6,10 +6,11 @@ import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx
6
6
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
7
7
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
8
8
  import pino from "pino";
9
+ import { randomUUID } from "node:crypto";
9
10
  import { createRequire } from "node:module";
10
11
  import { join } from "node:path";
11
- import { pathToFileURL } from "node:url";
12
- import { CloudConnectorController } from "./cloud-connector.js";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { ClientCloudDiscordDirectory, CloudConnectorController, } from "./cloud-connector.js";
13
14
  import { createCloudManagementModule } from "./cloud-management-module.js";
14
15
  import { installInteractions } from "./interactions.js";
15
16
  import { installGuildTracking } from "./guilds.js";
@@ -20,14 +21,29 @@ import { createManagementModule } from "./management-module.js";
20
21
  import { installModuleEvents } from "./events.js";
21
22
  import { DiscordModuleLoggingService } from "./module-logging.js";
22
23
  import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
24
+ import { startShardSupervisor } from "./shard-supervisor.js";
25
+ import { parseShardWorkerLaunchContract, startShardWorker, } from "./shard-worker.js";
26
+ import { createDeveloperOperationsModule } from "./developer-operations-module.js";
27
+ import { shardHealthSnapshotSchema } from "./shard-contracts.js";
23
28
  export { loadConfiguredModulePackages } from "./modules.js";
24
29
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
25
30
  export * from "./control-server.js";
26
31
  const bootstrapLogger = pino({ base: { component: "bot" } });
27
32
  const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
28
33
  export async function startBot(processEnvironment = process.env) {
34
+ if (processEnvironment.HELYX_SHARD_WORKER_CONTRACT !== undefined)
35
+ return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
29
36
  const environment = parseBotEnvironment(processEnvironment);
30
37
  bootstrapLogger.level = environment.LOG_LEVEL;
38
+ if (environment.HELYX_SHARD_MODE !== "off")
39
+ return startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint());
40
+ return startUnshardedBot(processEnvironment);
41
+ }
42
+ export function resolveBotWorkerEntryPoint(entryUrl = import.meta.url) {
43
+ return fileURLToPath(entryUrl);
44
+ }
45
+ async function startUnshardedBot(processEnvironment) {
46
+ const environment = parseBotEnvironment(processEnvironment);
31
47
  const database = createDatabasePool({
32
48
  connectionString: environment.DATABASE_URL,
33
49
  sslMode: environment.DATABASE_SSL,
@@ -113,7 +129,7 @@ export async function startBot(processEnvironment = process.env) {
113
129
  const cloudConnector = new CloudConnectorController({
114
130
  environment,
115
131
  database,
116
- client,
132
+ discord: new ClientCloudDiscordDirectory(client),
117
133
  modules: modulePackages,
118
134
  coreVersion: readBotVersion(),
119
135
  logger: bootstrapLogger,
@@ -127,15 +143,85 @@ export async function startBot(processEnvironment = process.env) {
127
143
  configuredOwnerUserIds: environment.HELYX_OWNER_USER_IDS,
128
144
  })
129
145
  : undefined;
130
- const modules = [
146
+ const publicModules = [
131
147
  managementModule,
132
148
  permissionsModule,
133
149
  ...(cloudManagementModule ? [cloudManagementModule] : []),
134
150
  ...featureModules,
135
151
  ];
136
- const platformServices = new Map(createPlatformServices(database, modules));
152
+ const unshardedSupervisorId = randomUUID();
153
+ const deployedCommit = deploymentReference(processEnvironment);
154
+ const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
155
+ ? createDeveloperOperationsModule({
156
+ internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
157
+ approvedRoleIds: environment.APPROVED_ROLES,
158
+ botVersion: readBotVersion(),
159
+ ...(deployedCommit ? { deploymentReference: deployedCommit } : {}),
160
+ coreFunctions: [
161
+ managementModule,
162
+ permissionsModule,
163
+ ...(cloudManagementModule ? [cloudManagementModule] : []),
164
+ ].map(({ manifest }) => manifest),
165
+ modules: featureModules.map(({ manifest }) => manifest),
166
+ processLabel: "unsharded bot process",
167
+ health: {
168
+ getHealth: async () => {
169
+ let databaseHealthy = true;
170
+ try {
171
+ await checkDatabaseConnection(database);
172
+ }
173
+ catch {
174
+ databaseHealthy = false;
175
+ }
176
+ const ready = client.isReady();
177
+ const observedAt = new Date().toISOString();
178
+ const gatewayPingMs = Number.isFinite(client.ws.ping)
179
+ ? client.ws.ping
180
+ : -1;
181
+ return shardHealthSnapshotSchema.parse({
182
+ version: 1,
183
+ mode: "off",
184
+ scope: "local",
185
+ status: ready && databaseHealthy
186
+ ? "healthy"
187
+ : ready
188
+ ? "degraded"
189
+ : "starting",
190
+ observedAt,
191
+ generation: "unsharded",
192
+ supervisorId: unshardedSupervisorId,
193
+ configuredShardCount: 1,
194
+ readyShardCount: ready ? 1 : 0,
195
+ coordination: {
196
+ redis: "not_required",
197
+ database: databaseHealthy ? "healthy" : "unavailable",
198
+ },
199
+ shards: [
200
+ {
201
+ shardId: 0,
202
+ status: ready ? "ready" : "starting",
203
+ guildCount: client.guilds.cache.size,
204
+ gatewayPingMs,
205
+ uptimeSeconds: process.uptime(),
206
+ restartCount: 0,
207
+ eventLoopDelayMs: 0,
208
+ memoryRssBytes: process.memoryUsage().rss,
209
+ queueDepth: 0,
210
+ observedAt,
211
+ },
212
+ ],
213
+ });
214
+ },
215
+ },
216
+ })
217
+ : undefined;
218
+ const modules = [
219
+ ...publicModules,
220
+ ...(operationsModule ? [operationsModule] : []),
221
+ ];
222
+ const platformServices = new Map(createPlatformServices(database, publicModules));
137
223
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
138
- platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, modules, bootstrapLogger));
224
+ platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
139
225
  }
140
226
  const serviceAccess = new ServiceRegistry(platformServices);
141
227
  const runtime = createRuntime({
@@ -147,16 +233,24 @@ export async function startBot(processEnvironment = process.env) {
147
233
  managementModule.manifest.id,
148
234
  permissionsModule.manifest.id,
149
235
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
236
+ ...(operationsModule ? [operationsModule.manifest.id] : []),
150
237
  ]);
151
- const prepareGuilds = installGuildTracking(client, database, modules, bootstrapLogger, enabledCoreModuleIds);
238
+ const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds);
152
239
  const commandsReady = installInteractions(client, modules, {
153
240
  ...(environment.DISCORD_INTERNAL_GUILD_ID
154
241
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
155
242
  : {}),
156
243
  prepare: prepareGuilds,
244
+ ...(operationsModule
245
+ ? {
246
+ selfAuthorizingGuildModuleIds: new Set([
247
+ operationsModule.manifest.id,
248
+ ]),
249
+ }
250
+ : {}),
157
251
  }, bootstrapLogger, serviceAccess);
158
- installModuleEvents(client, modules, bootstrapLogger, serviceAccess);
159
- const controlService = new BotControlService(database, modules, guildResources, serviceAccess, privacyDatabase
252
+ const moduleEvents = installModuleEvents(client, publicModules, bootstrapLogger, serviceAccess);
253
+ const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess, privacyDatabase
160
254
  ? new PostgresPrivacyProviderExecutor({
161
255
  readPool: database,
162
256
  executorPool: privacyDatabase,
@@ -185,6 +279,7 @@ export async function startBot(processEnvironment = process.env) {
185
279
  bootstrapLogger.error({ event: "control.stop_failed", error: safeError(error) }, "Private bot control shutdown failed");
186
280
  });
187
281
  try {
282
+ await moduleEvents.stop();
188
283
  await runtime.stop();
189
284
  }
190
285
  catch (error) {
@@ -230,6 +325,7 @@ export async function startBot(processEnvironment = process.env) {
230
325
  if (controlServer)
231
326
  await controlServer.close().catch(() => undefined);
232
327
  try {
328
+ await moduleEvents.stop();
233
329
  await runtime.stop();
234
330
  }
235
331
  catch (stopError) {
@@ -258,7 +354,7 @@ async function assertDistinctDatabaseRoles(runtime, privacy) {
258
354
  privacyIdentity.rows[0]?.current_user)
259
355
  throw new Error("Privacy executor must use a distinct least-privilege database role");
260
356
  }
261
- function readBotVersion() {
357
+ export function readBotVersion() {
262
358
  const metadata = createRequire(import.meta.url)("../package.json");
263
359
  if (typeof metadata !== "object" ||
264
360
  metadata === null ||
@@ -266,6 +362,11 @@ function readBotVersion() {
266
362
  throw new Error("Bot package version is unavailable");
267
363
  return metadata.version;
268
364
  }
365
+ function deploymentReference(processEnvironment) {
366
+ const value = processEnvironment.RAILWAY_GIT_COMMIT_SHA ??
367
+ processEnvironment.GIT_COMMIT_SHA;
368
+ return value?.trim().slice(0, 12) || undefined;
369
+ }
269
370
  export function gatewayIntentsForModules(modules) {
270
371
  const intents = [GatewayIntentBits.Guilds];
271
372
  if (modules.some((module) => module.events?.messageReactionAdd?.length)) {