@helyx/bot 0.3.0 → 0.4.1
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 +42 -0
- package/README.md +68 -0
- package/dist/cloud-connector.d.ts +36 -2
- package/dist/cloud-connector.js +41 -20
- package/dist/cloud-management-module.d.ts +5 -3
- package/dist/cloud-management-module.js +27 -18
- package/dist/command-sync.d.ts +32 -0
- package/dist/command-sync.js +67 -0
- package/dist/control-server.d.ts +10 -0
- package/dist/control-server.js +86 -18
- package/dist/developer-operations-module.d.ts +28 -0
- package/dist/developer-operations-module.js +249 -0
- package/dist/events.d.ts +5 -1
- package/dist/events.js +69 -3
- package/dist/guilds.d.ts +2 -2
- package/dist/guilds.js +17 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +111 -10
- package/dist/interactions.d.ts +8 -1
- package/dist/interactions.js +50 -24
- package/dist/shard-cloud-proxy.d.ts +21 -0
- package/dist/shard-cloud-proxy.js +94 -0
- package/dist/shard-contracts.d.ts +751 -0
- package/dist/shard-contracts.js +445 -0
- package/dist/shard-coordinator.d.ts +90 -0
- package/dist/shard-coordinator.js +620 -0
- package/dist/shard-router.d.ts +28 -0
- package/dist/shard-router.js +211 -0
- package/dist/shard-supervisor.d.ts +36 -0
- package/dist/shard-supervisor.js +1230 -0
- package/dist/shard-worker.d.ts +62 -0
- package/dist/shard-worker.js +380 -0
- package/dist/supervisor-cloud-directory.d.ts +11 -0
- package/dist/supervisor-cloud-directory.js +66 -0
- package/package.json +9 -8
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type CommandRegistration } from "./interactions.js";
|
|
2
|
+
import { type ShardIdentity } from "./shard-contracts.js";
|
|
3
|
+
import { type BotHandle } from "./index.js";
|
|
4
|
+
export declare const WORKER_ORPHAN_TERMINATION_BOUND_MS: number;
|
|
5
|
+
export declare function createLeaseHeartbeatWatchdog(onExpired: () => void, timeoutMs?: number): {
|
|
6
|
+
heartbeat(): void;
|
|
7
|
+
stop(): void;
|
|
8
|
+
};
|
|
9
|
+
export declare function startShardWorker(processEnvironment?: NodeJS.ProcessEnv, identity?: {
|
|
10
|
+
version: 1;
|
|
11
|
+
mode: "auto" | "fixed";
|
|
12
|
+
scope: "local" | "coordinated";
|
|
13
|
+
generation: string;
|
|
14
|
+
supervisorId: string;
|
|
15
|
+
shardId: number;
|
|
16
|
+
shardCount: number;
|
|
17
|
+
fencingToken: number;
|
|
18
|
+
deploymentOrder?: number | undefined;
|
|
19
|
+
}): Promise<BotHandle>;
|
|
20
|
+
export declare function parseShardWorkerLaunchContract(value: string | undefined): ShardIdentity;
|
|
21
|
+
export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.ProcessEnv, identity: ShardIdentity): {
|
|
22
|
+
HELYX_SHARD_WORKERS_PER_REPLICA: number;
|
|
23
|
+
HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: number | undefined;
|
|
24
|
+
HELYX_SHARD_WORKER_DATABASE_POOL_MAX: number | undefined;
|
|
25
|
+
DATABASE_URL: string;
|
|
26
|
+
DATABASE_SSL: "disable" | "require" | "verify-full";
|
|
27
|
+
DISCORD_TOKEN: string;
|
|
28
|
+
DISCORD_CLIENT_ID: string;
|
|
29
|
+
DISCORD_INTERNAL_GUILD_ID: string | undefined;
|
|
30
|
+
APPROVED_ROLES: string[];
|
|
31
|
+
HELYX_MODULE_PACKAGES: string[];
|
|
32
|
+
HELYX_MODULE_CATALOGUE_PATH: string | undefined;
|
|
33
|
+
HELYX_BOT_CONTROL_PORT: number;
|
|
34
|
+
HELYX_PRIVACY_ENABLED: boolean;
|
|
35
|
+
HELYX_CLOUD_CONNECT_ENABLED: boolean;
|
|
36
|
+
HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
|
|
37
|
+
HELYX_CLOUD_ENROLMENT_FILE: string;
|
|
38
|
+
HELYX_CLOUD_API_URL: string;
|
|
39
|
+
HELYX_CLOUD_ENDPOINT: string;
|
|
40
|
+
HELYX_OWNER_USER_IDS: string[];
|
|
41
|
+
HELYX_SHARD_MODE: "off" | "auto" | "fixed";
|
|
42
|
+
HELYX_SHARD_SCOPE: "local" | "coordinated";
|
|
43
|
+
HELYX_CLOUD_AUDIT_RETENTION_DAYS: number;
|
|
44
|
+
HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: number;
|
|
45
|
+
LOG_LEVEL: "error" | "fatal" | "warn" | "info" | "debug" | "trace" | "silent";
|
|
46
|
+
NODE_ENV: "development" | "test" | "production";
|
|
47
|
+
HELYX_BOT_CONTROL_SECRET?: string | undefined;
|
|
48
|
+
HELYX_PRIVACY_EXECUTOR_DATABASE_URL?: string | undefined;
|
|
49
|
+
HELYX_PRIVACY_EXECUTOR_DATABASE_SSL?: "disable" | "require" | "verify-full" | undefined;
|
|
50
|
+
HELYX_DATA_ENCRYPTION_KEY?: string | undefined;
|
|
51
|
+
HELYX_SHARD_COUNT?: number | undefined;
|
|
52
|
+
HELYX_SHARD_REDIS_URL?: string | undefined;
|
|
53
|
+
HELYX_SHARD_REDIS_NAMESPACE?: string | undefined;
|
|
54
|
+
HELYX_SHARD_GENERATION?: string | undefined;
|
|
55
|
+
HELYX_SHARD_DATABASE_CONNECTION_BUDGET?: number | undefined;
|
|
56
|
+
};
|
|
57
|
+
export declare function shardWorkerCommandRegistration(input: {
|
|
58
|
+
internalGuildId?: string;
|
|
59
|
+
prepare: NonNullable<CommandRegistration["prepare"]>;
|
|
60
|
+
operationsModuleId?: string;
|
|
61
|
+
}): CommandRegistration;
|
|
62
|
+
//# sourceMappingURL=shard-worker.d.ts.map
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { parseBotEnvironment } from "@helyx/config";
|
|
2
|
+
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
3
|
+
import { checkDatabaseConnection, createDatabasePool } from "@helyx/database";
|
|
4
|
+
import { createRuntime } from "@helyx/framework";
|
|
5
|
+
import { createPlatformServices } from "@helyx/platform";
|
|
6
|
+
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
7
|
+
import { Client, Events, Partials } from "discord.js";
|
|
8
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
9
|
+
import pino from "pino";
|
|
10
|
+
import { createCloudManagementModule } from "./cloud-management-module.js";
|
|
11
|
+
import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
|
|
12
|
+
import { installModuleEvents } from "./events.js";
|
|
13
|
+
import { installGuildTracking } from "./guilds.js";
|
|
14
|
+
import { installInteractions, } from "./interactions.js";
|
|
15
|
+
import { createManagementModule } from "./management-module.js";
|
|
16
|
+
import { DiscordModuleLoggingService } from "./module-logging.js";
|
|
17
|
+
import { resolveModulePackageSpecifiers } from "./module-configuration.js";
|
|
18
|
+
import { loadConfiguredModulePackages } from "./modules.js";
|
|
19
|
+
import { createPermissionsModule } from "./permissions-module.js";
|
|
20
|
+
import { WorkerCloudConnectorProxy } from "./shard-cloud-proxy.js";
|
|
21
|
+
import { createDeveloperOperationsModule, WorkerShardHealthProvider, } from "./developer-operations-module.js";
|
|
22
|
+
import { assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, guildIdToShardId, SHARD_LEASE_HEARTBEAT_TIMEOUT_MS, shardIdentitySchema, supervisorMessageSchema, } from "./shard-contracts.js";
|
|
23
|
+
import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
|
|
24
|
+
const HEALTH_INTERVAL_MS = 5_000;
|
|
25
|
+
export const WORKER_ORPHAN_TERMINATION_BOUND_MS = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS + 5_000;
|
|
26
|
+
export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS) {
|
|
27
|
+
let timer;
|
|
28
|
+
const heartbeat = () => {
|
|
29
|
+
if (timer)
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
timer = setTimeout(onExpired, timeoutMs);
|
|
32
|
+
timer.unref();
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
heartbeat,
|
|
36
|
+
stop: () => {
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
timer = undefined;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function startShardWorker(processEnvironment = process.env, identity = parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT)) {
|
|
44
|
+
const environment = parseShardWorkerEnvironment(processEnvironment, identity);
|
|
45
|
+
const logger = pino({
|
|
46
|
+
level: environment.LOG_LEVEL,
|
|
47
|
+
base: {
|
|
48
|
+
component: "bot-worker",
|
|
49
|
+
shardId: identity.shardId,
|
|
50
|
+
shardCount: identity.shardCount,
|
|
51
|
+
supervisorId: identity.supervisorId,
|
|
52
|
+
generation: identity.generation,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const database = createDatabasePool({
|
|
56
|
+
connectionString: environment.DATABASE_URL,
|
|
57
|
+
sslMode: environment.DATABASE_SSL,
|
|
58
|
+
applicationName: `helyx-bot-shard-${String(identity.shardId)}`,
|
|
59
|
+
...(environment.HELYX_SHARD_WORKER_DATABASE_POOL_MAX === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: { maxConnections: environment.HELYX_SHARD_WORKER_DATABASE_POOL_MAX }),
|
|
62
|
+
});
|
|
63
|
+
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
64
|
+
eventLoopDelay.enable();
|
|
65
|
+
let status = "starting";
|
|
66
|
+
let healthTimer;
|
|
67
|
+
let shuttingDown = false;
|
|
68
|
+
let runtimeStarted = false;
|
|
69
|
+
let moduleEvents;
|
|
70
|
+
let client;
|
|
71
|
+
let cloudProxy;
|
|
72
|
+
let shardHealthProvider;
|
|
73
|
+
let leaseHeartbeatWatchdog;
|
|
74
|
+
let forcedLeaseExit;
|
|
75
|
+
async function shutdown(reason = "programmatic") {
|
|
76
|
+
if (shuttingDown)
|
|
77
|
+
return;
|
|
78
|
+
shuttingDown = true;
|
|
79
|
+
status = "stopped";
|
|
80
|
+
if (healthTimer)
|
|
81
|
+
clearInterval(healthTimer);
|
|
82
|
+
leaseHeartbeatWatchdog?.stop();
|
|
83
|
+
if (forcedLeaseExit)
|
|
84
|
+
clearTimeout(forcedLeaseExit);
|
|
85
|
+
sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0));
|
|
86
|
+
await cloudProxy?.stop().catch(() => undefined);
|
|
87
|
+
shardHealthProvider?.stop();
|
|
88
|
+
await moduleEvents?.stop().catch(() => undefined);
|
|
89
|
+
if (runtimeStarted)
|
|
90
|
+
await runtime?.stop().catch(() => undefined);
|
|
91
|
+
await client?.destroy();
|
|
92
|
+
eventLoopDelay.disable();
|
|
93
|
+
await database.end();
|
|
94
|
+
logger.info({ event: "shard.stopped", reason }, "Shard worker stopped");
|
|
95
|
+
}
|
|
96
|
+
let runtime;
|
|
97
|
+
try {
|
|
98
|
+
await checkDatabaseConnection(database);
|
|
99
|
+
const moduleSpecifiers = await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH);
|
|
100
|
+
const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
|
|
101
|
+
const featureModules = modulePackages.map(({ definition }) => definition);
|
|
102
|
+
client = new Client({
|
|
103
|
+
intents: gatewayIntentsForModules(featureModules),
|
|
104
|
+
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
|
105
|
+
allowedMentions: { parse: [] },
|
|
106
|
+
shards: [identity.shardId],
|
|
107
|
+
shardCount: identity.shardCount,
|
|
108
|
+
});
|
|
109
|
+
const guildResources = new DiscordGuildResourceDirectory(client);
|
|
110
|
+
const permissionsModule = createPermissionsModule(featureModules);
|
|
111
|
+
const managementModule = createManagementModule(featureModules, guildResources);
|
|
112
|
+
cloudProxy = environment.HELYX_CLOUD_CONNECT_ENABLED
|
|
113
|
+
? new WorkerCloudConnectorProxy()
|
|
114
|
+
: undefined;
|
|
115
|
+
const cloudManagementModule = cloudProxy
|
|
116
|
+
? createCloudManagementModule({
|
|
117
|
+
connector: cloudProxy,
|
|
118
|
+
client,
|
|
119
|
+
configuredOwnerUserIds: environment.HELYX_OWNER_USER_IDS,
|
|
120
|
+
periodicOwnerRefresh: false,
|
|
121
|
+
})
|
|
122
|
+
: undefined;
|
|
123
|
+
const publicModules = [
|
|
124
|
+
managementModule,
|
|
125
|
+
permissionsModule,
|
|
126
|
+
...(cloudManagementModule ? [cloudManagementModule] : []),
|
|
127
|
+
...featureModules,
|
|
128
|
+
];
|
|
129
|
+
shardHealthProvider = new WorkerShardHealthProvider();
|
|
130
|
+
const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
|
|
131
|
+
? createDeveloperOperationsModule({
|
|
132
|
+
internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
|
|
133
|
+
approvedRoleIds: environment.APPROVED_ROLES,
|
|
134
|
+
botVersion: readBotVersion(),
|
|
135
|
+
...(processEnvironment.RAILWAY_GIT_COMMIT_SHA
|
|
136
|
+
? {
|
|
137
|
+
deploymentReference: processEnvironment.RAILWAY_GIT_COMMIT_SHA.slice(0, 12),
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
coreFunctions: [
|
|
141
|
+
managementModule,
|
|
142
|
+
permissionsModule,
|
|
143
|
+
...(cloudManagementModule ? [cloudManagementModule] : []),
|
|
144
|
+
].map(({ manifest }) => manifest),
|
|
145
|
+
modules: featureModules.map(({ manifest }) => manifest),
|
|
146
|
+
processLabel: `shard worker ${identity.shardId}`,
|
|
147
|
+
health: shardHealthProvider,
|
|
148
|
+
})
|
|
149
|
+
: undefined;
|
|
150
|
+
const modules = [
|
|
151
|
+
...publicModules,
|
|
152
|
+
...(operationsModule ? [operationsModule] : []),
|
|
153
|
+
];
|
|
154
|
+
const platformServices = new Map(createPlatformServices(database, publicModules));
|
|
155
|
+
if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging"))
|
|
156
|
+
platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, logger));
|
|
157
|
+
const serviceAccess = new ServiceRegistry(platformServices);
|
|
158
|
+
runtime = createRuntime({ modules, logger, services: platformServices });
|
|
159
|
+
const enabledCoreModuleIds = new Set([
|
|
160
|
+
managementModule.manifest.id,
|
|
161
|
+
permissionsModule.manifest.id,
|
|
162
|
+
...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
|
|
163
|
+
...(operationsModule ? [operationsModule.manifest.id] : []),
|
|
164
|
+
]);
|
|
165
|
+
const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, {
|
|
166
|
+
generation: identity.generation,
|
|
167
|
+
deploymentOrder: BigInt(identity.deploymentOrder ?? 1),
|
|
168
|
+
supervisorId: identity.supervisorId,
|
|
169
|
+
shardId: identity.shardId,
|
|
170
|
+
fencingToken: BigInt(identity.fencingToken),
|
|
171
|
+
});
|
|
172
|
+
const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
|
|
173
|
+
...(environment.DISCORD_INTERNAL_GUILD_ID
|
|
174
|
+
? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
|
|
175
|
+
: {}),
|
|
176
|
+
prepare: prepareGuilds,
|
|
177
|
+
...(operationsModule
|
|
178
|
+
? { operationsModuleId: operationsModule.manifest.id }
|
|
179
|
+
: {}),
|
|
180
|
+
}), logger, serviceAccess);
|
|
181
|
+
moduleEvents = installModuleEvents(client, publicModules, logger, serviceAccess);
|
|
182
|
+
const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess);
|
|
183
|
+
process.on("message", (raw) => {
|
|
184
|
+
const parsed = supervisorMessageSchema.safeParse(raw);
|
|
185
|
+
if (!parsed.success)
|
|
186
|
+
return;
|
|
187
|
+
if (parsed.data.type === "helyx.control.request") {
|
|
188
|
+
void handleControl(identity, controlService, parsed.data);
|
|
189
|
+
}
|
|
190
|
+
else if (parsed.data.type === "helyx.lease.heartbeat") {
|
|
191
|
+
if (sameShardIdentity(parsed.data.identity, identity))
|
|
192
|
+
leaseHeartbeatWatchdog?.heartbeat();
|
|
193
|
+
}
|
|
194
|
+
else if (parsed.data.type === "helyx.cloud.response") {
|
|
195
|
+
cloudProxy?.accept(parsed.data);
|
|
196
|
+
}
|
|
197
|
+
else if (parsed.data.type === "helyx.health.request") {
|
|
198
|
+
sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0));
|
|
199
|
+
}
|
|
200
|
+
else if (parsed.data.type === "helyx.operations.health.response") {
|
|
201
|
+
shardHealthProvider?.accept(parsed.data);
|
|
202
|
+
}
|
|
203
|
+
else if (parsed.data.type === "helyx.shutdown") {
|
|
204
|
+
void shutdown(parsed.data.reason).finally(() => {
|
|
205
|
+
if (process.connected)
|
|
206
|
+
process.disconnect?.();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
if (identity.scope === "coordinated") {
|
|
211
|
+
leaseHeartbeatWatchdog = createLeaseHeartbeatWatchdog(() => {
|
|
212
|
+
logger.error({ event: "shard.supervisor_heartbeat_lost" }, "Supervisor liveness heartbeat was lost; stopping shard worker");
|
|
213
|
+
void shutdown("supervisor-heartbeat-lost").finally(() => {
|
|
214
|
+
if (process.connected)
|
|
215
|
+
process.disconnect?.();
|
|
216
|
+
});
|
|
217
|
+
forcedLeaseExit = setTimeout(() => process.exit(1), 5_000);
|
|
218
|
+
forcedLeaseExit.unref();
|
|
219
|
+
});
|
|
220
|
+
leaseHeartbeatWatchdog.heartbeat();
|
|
221
|
+
}
|
|
222
|
+
client.on(Events.ShardReady, () => {
|
|
223
|
+
status = "ready";
|
|
224
|
+
sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0));
|
|
225
|
+
});
|
|
226
|
+
client.on(Events.ShardDisconnect, () => {
|
|
227
|
+
status = "disconnected";
|
|
228
|
+
sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0));
|
|
229
|
+
});
|
|
230
|
+
client.on(Events.ShardReconnecting, () => {
|
|
231
|
+
status = "reconnecting";
|
|
232
|
+
});
|
|
233
|
+
client.on(Events.ShardResume, () => {
|
|
234
|
+
status = "ready";
|
|
235
|
+
});
|
|
236
|
+
client.on(Events.Error, (error) => {
|
|
237
|
+
logger.error({ event: "discord.error", error: safeError(error) }, "Discord shard error");
|
|
238
|
+
});
|
|
239
|
+
await cloudProxy?.start();
|
|
240
|
+
await runtime.start();
|
|
241
|
+
runtimeStarted = true;
|
|
242
|
+
await client.login(environment.DISCORD_TOKEN);
|
|
243
|
+
await commandsReady;
|
|
244
|
+
healthTimer = setInterval(() => sendHealth(identity, health(identity, client, status, eventLoopDelay.mean, moduleEvents?.queueDepth() ?? 0)), HEALTH_INTERVAL_MS);
|
|
245
|
+
healthTimer.unref();
|
|
246
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
247
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
248
|
+
return { shutdown };
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
logger.error({ event: "shard.start_failed", error: safeError(error) }, "Shard worker failed to start");
|
|
252
|
+
await shutdown("startup-failed");
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function sameShardIdentity(left, right) {
|
|
257
|
+
return (left.generation === right.generation &&
|
|
258
|
+
left.deploymentOrder === right.deploymentOrder &&
|
|
259
|
+
left.supervisorId === right.supervisorId &&
|
|
260
|
+
left.shardId === right.shardId &&
|
|
261
|
+
left.shardCount === right.shardCount &&
|
|
262
|
+
left.fencingToken === right.fencingToken);
|
|
263
|
+
}
|
|
264
|
+
export function parseShardWorkerLaunchContract(value) {
|
|
265
|
+
if (!value)
|
|
266
|
+
throw new Error("Shard worker launch contract is missing");
|
|
267
|
+
try {
|
|
268
|
+
return shardIdentitySchema.parse(JSON.parse(value));
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof SyntaxError)
|
|
272
|
+
throw new Error("Shard worker launch contract is invalid JSON", {
|
|
273
|
+
cause: error,
|
|
274
|
+
});
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export function parseShardWorkerEnvironment(processEnvironment, identity) {
|
|
279
|
+
const cloudEnabled = processEnvironment.HELYX_CLOUD_CONNECT_ENABLED === "true";
|
|
280
|
+
const environment = parseBotEnvironment({
|
|
281
|
+
...processEnvironment,
|
|
282
|
+
...(cloudEnabled
|
|
283
|
+
? {
|
|
284
|
+
HELYX_BOT_CONTROL_SECRET: Buffer.alloc(32).toString("base64"),
|
|
285
|
+
HELYX_DATA_ENCRYPTION_KEY: Buffer.alloc(32).toString("base64"),
|
|
286
|
+
}
|
|
287
|
+
: {}),
|
|
288
|
+
...(identity.scope === "coordinated"
|
|
289
|
+
? { HELYX_SHARD_REDIS_URL: "redis://worker-validation.invalid" }
|
|
290
|
+
: {}),
|
|
291
|
+
});
|
|
292
|
+
if (environment.HELYX_SHARD_MODE === "off" ||
|
|
293
|
+
environment.HELYX_SHARD_MODE !== identity.mode ||
|
|
294
|
+
environment.HELYX_SHARD_SCOPE !== identity.scope)
|
|
295
|
+
throw new Error("Shard worker launch contract conflicts with configuration");
|
|
296
|
+
return environment;
|
|
297
|
+
}
|
|
298
|
+
export function shardWorkerCommandRegistration(input) {
|
|
299
|
+
return {
|
|
300
|
+
...(input.internalGuildId
|
|
301
|
+
? { internalGuildId: input.internalGuildId }
|
|
302
|
+
: {}),
|
|
303
|
+
prepare: input.prepare,
|
|
304
|
+
synchronise: false,
|
|
305
|
+
...(input.operationsModuleId
|
|
306
|
+
? {
|
|
307
|
+
selfAuthorizingGuildModuleIds: new Set([input.operationsModuleId]),
|
|
308
|
+
}
|
|
309
|
+
: {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async function handleControl(identity, service, request) {
|
|
313
|
+
let response;
|
|
314
|
+
const guildId = controlOperationGuildId(request.operation);
|
|
315
|
+
if (request.generation !== identity.generation ||
|
|
316
|
+
request.fencingToken !== identity.fencingToken ||
|
|
317
|
+
!guildId ||
|
|
318
|
+
guildIdToShardId(guildId, identity.shardCount) !== identity.shardId ||
|
|
319
|
+
new Date(request.deadlineAt).getTime() <= Date.now()) {
|
|
320
|
+
response = {
|
|
321
|
+
version: 1,
|
|
322
|
+
type: "helyx.control.response",
|
|
323
|
+
requestId: request.requestId,
|
|
324
|
+
ok: false,
|
|
325
|
+
status: 503,
|
|
326
|
+
message: "Shard ownership changed or the request expired",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
try {
|
|
331
|
+
response = {
|
|
332
|
+
version: 1,
|
|
333
|
+
type: "helyx.control.response",
|
|
334
|
+
requestId: request.requestId,
|
|
335
|
+
ok: true,
|
|
336
|
+
result: await executeShardControlOperation(service, request.operation),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
const mapped = mapBotControlError(error);
|
|
341
|
+
response = {
|
|
342
|
+
version: 1,
|
|
343
|
+
type: "helyx.control.response",
|
|
344
|
+
requestId: request.requestId,
|
|
345
|
+
ok: false,
|
|
346
|
+
status: mapped?.status ?? 500,
|
|
347
|
+
message: mapped?.message ?? "The owning shard rejected the request",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
assertShardMessageSize(response);
|
|
352
|
+
process.send?.(response);
|
|
353
|
+
}
|
|
354
|
+
function health(identity, client, status, eventLoopDelayNanoseconds, queueDepth) {
|
|
355
|
+
return {
|
|
356
|
+
shardId: identity.shardId,
|
|
357
|
+
status,
|
|
358
|
+
guildCount: client?.guilds.cache.size ?? 0,
|
|
359
|
+
gatewayPingMs: client?.ws.ping ?? -1,
|
|
360
|
+
uptimeSeconds: process.uptime(),
|
|
361
|
+
restartCount: Number(process.env.HELYX_SHARD_RESTART_COUNT ?? "0"),
|
|
362
|
+
eventLoopDelayMs: Number.isFinite(eventLoopDelayNanoseconds)
|
|
363
|
+
? eventLoopDelayNanoseconds / 1_000_000
|
|
364
|
+
: 0,
|
|
365
|
+
memoryRssBytes: process.memoryUsage().rss,
|
|
366
|
+
queueDepth,
|
|
367
|
+
observedAt: new Date().toISOString(),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function sendHealth(identity, workerHealth) {
|
|
371
|
+
const message = {
|
|
372
|
+
version: 1,
|
|
373
|
+
type: "helyx.health.update",
|
|
374
|
+
identity,
|
|
375
|
+
health: workerHealth,
|
|
376
|
+
};
|
|
377
|
+
assertShardMessageSize(message);
|
|
378
|
+
process.send?.(message);
|
|
379
|
+
}
|
|
380
|
+
//# sourceMappingURL=shard-worker.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DatabasePool } from "@helyx/database";
|
|
2
|
+
import type { CloudDiscordDirectory, CloudDiscordSnapshot } from "./cloud-connector.js";
|
|
3
|
+
export declare const CLOUD_HELLO_GUILD_LIMIT = 10000;
|
|
4
|
+
export declare class SupervisorCloudDiscordDirectory implements CloudDiscordDirectory {
|
|
5
|
+
#private;
|
|
6
|
+
private readonly database;
|
|
7
|
+
private readonly configuredOwnerUserIds;
|
|
8
|
+
constructor(token: string, database: DatabasePool, configuredOwnerUserIds: readonly string[]);
|
|
9
|
+
snapshot(): Promise<CloudDiscordSnapshot>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=supervisor-cloud-directory.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CONNECTOR_HELLO_MAX_GUILDS } from "@helyx/control-protocol";
|
|
2
|
+
import { REST, Routes } from "discord.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const snowflake = z.string().regex(/^\d{17,20}$/u);
|
|
5
|
+
export const CLOUD_HELLO_GUILD_LIMIT = CONNECTOR_HELLO_MAX_GUILDS;
|
|
6
|
+
const applicationSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
id: snowflake,
|
|
9
|
+
owner: z
|
|
10
|
+
.union([
|
|
11
|
+
z.object({ id: snowflake }).passthrough(),
|
|
12
|
+
z
|
|
13
|
+
.object({
|
|
14
|
+
members: z
|
|
15
|
+
.array(z.object({ user: z.object({ id: snowflake }) }))
|
|
16
|
+
.optional(),
|
|
17
|
+
})
|
|
18
|
+
.passthrough(),
|
|
19
|
+
])
|
|
20
|
+
.nullable()
|
|
21
|
+
.optional(),
|
|
22
|
+
})
|
|
23
|
+
.passthrough();
|
|
24
|
+
const userSchema = z
|
|
25
|
+
.object({ id: snowflake, username: z.string().min(1).max(100) })
|
|
26
|
+
.passthrough();
|
|
27
|
+
export class SupervisorCloudDiscordDirectory {
|
|
28
|
+
database;
|
|
29
|
+
configuredOwnerUserIds;
|
|
30
|
+
#rest;
|
|
31
|
+
constructor(token, database, configuredOwnerUserIds) {
|
|
32
|
+
this.database = database;
|
|
33
|
+
this.configuredOwnerUserIds = configuredOwnerUserIds;
|
|
34
|
+
this.#rest = new REST({ version: "10" }).setToken(token);
|
|
35
|
+
}
|
|
36
|
+
async snapshot() {
|
|
37
|
+
const [applicationValue, botValue, guildResult] = await Promise.all([
|
|
38
|
+
this.#rest.get(Routes.oauth2CurrentApplication()),
|
|
39
|
+
this.#rest.get(Routes.user("@me")),
|
|
40
|
+
this.database.query(`SELECT guild_id, name, owner_user_id
|
|
41
|
+
FROM discord_guilds
|
|
42
|
+
WHERE active = true
|
|
43
|
+
ORDER BY guild_id
|
|
44
|
+
LIMIT ${String(CLOUD_HELLO_GUILD_LIMIT)}`),
|
|
45
|
+
]);
|
|
46
|
+
const application = applicationSchema.parse(applicationValue);
|
|
47
|
+
const bot = userSchema.parse(botValue);
|
|
48
|
+
const applicationOwnerIds = application.owner && "id" in application.owner
|
|
49
|
+
? [snowflake.parse(application.owner.id)]
|
|
50
|
+
: (application.owner?.members?.map((member) => snowflake.parse(member.user.id)) ?? []);
|
|
51
|
+
return {
|
|
52
|
+
applicationId: application.id,
|
|
53
|
+
botUserId: bot.id,
|
|
54
|
+
botUsername: bot.username,
|
|
55
|
+
ownerUserIds: [
|
|
56
|
+
...new Set([...this.configuredOwnerUserIds, ...applicationOwnerIds]),
|
|
57
|
+
],
|
|
58
|
+
guilds: guildResult.rows.map((guild) => ({
|
|
59
|
+
guildId: guild.guild_id,
|
|
60
|
+
name: guild.name ?? "Unknown server",
|
|
61
|
+
ownerUserId: guild.owner_user_id,
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=supervisor-cloud-directory.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helyx/bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Production Discord runtime for the modular Helyx platform.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"helyx",
|
|
@@ -48,17 +48,18 @@
|
|
|
48
48
|
"start": "node --env-file-if-exists=../../.env dist/index.js"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@helyx/config": "0.
|
|
52
|
-
"@helyx/control-protocol": "0.1.
|
|
53
|
-
"@helyx/core": "0.1.
|
|
54
|
-
"@helyx/database": "0.
|
|
55
|
-
"@helyx/framework": "0.1.
|
|
51
|
+
"@helyx/config": "0.4.1",
|
|
52
|
+
"@helyx/control-protocol": "0.1.2",
|
|
53
|
+
"@helyx/core": "0.1.7",
|
|
54
|
+
"@helyx/database": "0.4.0",
|
|
55
|
+
"@helyx/framework": "0.1.7",
|
|
56
56
|
"@helyx/module-manifest": "0.4.0",
|
|
57
|
-
"@helyx/platform": "0.2.
|
|
58
|
-
"@helyx/sdk": "0.3.
|
|
57
|
+
"@helyx/platform": "0.2.3",
|
|
58
|
+
"@helyx/sdk": "0.3.2",
|
|
59
59
|
"discord.js": "14.27.0",
|
|
60
60
|
"fastify": "5.10.0",
|
|
61
61
|
"pino": "10.3.1",
|
|
62
|
+
"redis": "6.1.0",
|
|
62
63
|
"ws": "8.21.1",
|
|
63
64
|
"zod": "4.4.3"
|
|
64
65
|
},
|