@helyx/bot 0.3.1 → 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 +34 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d25b465: Replace the user-specific shard diagnostic with a reusable, internal-guild-only
|
|
8
|
+
`/dev` command. Authorize Discord administrators or configured role IDs, expose
|
|
9
|
+
bounded shard, bot/module-version and process diagnostics, and keep the command
|
|
10
|
+
available for validating the unsharded compatibility baseline.
|
|
11
|
+
- Updated dependencies [d25b465]
|
|
12
|
+
- @helyx/config@0.4.1
|
|
13
|
+
- @helyx/sdk@0.3.2
|
|
14
|
+
- @helyx/core@0.1.7
|
|
15
|
+
- @helyx/framework@0.1.7
|
|
16
|
+
- @helyx/platform@0.2.3
|
|
17
|
+
|
|
18
|
+
## 0.4.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- 9cdb2ef: Add the opt-in shard supervisor/worker runtime, fenced coordination and routing,
|
|
23
|
+
durable command state, shard-aware guild ownership, and private operational
|
|
24
|
+
health while preserving unsharded defaults.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [9cdb2ef]
|
|
29
|
+
- @helyx/control-protocol@0.1.2
|
|
30
|
+
- @helyx/config@0.4.0
|
|
31
|
+
- @helyx/database@0.4.0
|
|
32
|
+
- @helyx/sdk@0.3.1
|
|
33
|
+
- @helyx/platform@0.2.2
|
|
34
|
+
- @helyx/core@0.1.6
|
|
35
|
+
- @helyx/framework@0.1.6
|
|
36
|
+
|
|
3
37
|
## 0.3.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -24,4 +24,72 @@ bot-control endpoint and a separate least-privilege PostgreSQL executor
|
|
|
24
24
|
credential. The bot refuses Privacy execution when that credential resolves to
|
|
25
25
|
the ordinary runtime database role.
|
|
26
26
|
|
|
27
|
+
## Shard configuration and operations
|
|
28
|
+
|
|
29
|
+
`HELYX_SHARD_MODE=off` is the default and compatibility baseline. It runs the
|
|
30
|
+
existing unsharded bot path. Do not set shard-only tuning values in this mode:
|
|
31
|
+
startup rejects shard count, workers, Redis, generation, database budget and
|
|
32
|
+
explicit database-pool settings. The internal `/dev` diagnostics remain
|
|
33
|
+
available in this mode when their guild and role configuration is present.
|
|
34
|
+
|
|
35
|
+
| Mode | Required values | Prohibited values | Behaviour |
|
|
36
|
+
| ------- | ---------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
37
|
+
| `off` | None | Shard count, workers, Redis, namespace, generation, budget and explicit pools | Existing single-process behaviour |
|
|
38
|
+
| `auto` | None for local mode | Shard count; Redis and namespace unless coordinated | Obtains Discord's recommended total |
|
|
39
|
+
| `fixed` | `HELYX_SHARD_COUNT` (1-4096) | None beyond the scope rules | Uses the deliberate total; workers per replica may not exceed it |
|
|
40
|
+
|
|
41
|
+
`HELYX_SHARD_SCOPE` defaults to `local`; use it for one supervisor host.
|
|
42
|
+
`coordinated` is only valid in a sharded mode and requires all of
|
|
43
|
+
`HELYX_SHARD_REDIS_URL` (`redis:` or `rediss:`),
|
|
44
|
+
`HELYX_SHARD_REDIS_NAMESPACE` (lowercase safe 3-128-character namespace),
|
|
45
|
+
and `HELYX_SHARD_GENERATION` (safe 1-128-character identifier). Redis URL and
|
|
46
|
+
namespace are forbidden in local mode. Generation is optional for local
|
|
47
|
+
sharded mode and forbidden when sharding is off.
|
|
48
|
+
|
|
49
|
+
`HELYX_SHARD_WORKERS_PER_REPLICA` is a positive integer up to 64 and defaults
|
|
50
|
+
to 1 when off or 2 when sharded. In sharded modes
|
|
51
|
+
`HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX` and
|
|
52
|
+
`HELYX_SHARD_WORKER_DATABASE_POOL_MAX` default to 2 and 4 respectively (each
|
|
53
|
+
may be 1-20).
|
|
54
|
+
`HELYX_SHARD_DATABASE_CONNECTION_BUDGET` is required for any production
|
|
55
|
+
sharded mode, optional elsewhere, and may not exceed 1000. When it is set,
|
|
56
|
+
the following must fit:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
supervisor pool + (workers per replica x worker pool) + (2 when Privacy is enabled) <= database budget
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Record the provider's actual connection, memory, CPU and replica limits before
|
|
63
|
+
choosing workers or pools; this repository does not declare a measured
|
|
64
|
+
production budget.
|
|
65
|
+
|
|
66
|
+
`DISCORD_INTERNAL_GUILD_ID` registers the private `/dev` command only in the
|
|
67
|
+
exact internal guild. `APPROVED_ROLES` is a de-duplicated, comma-separated
|
|
68
|
+
list of at most 100 Discord role IDs. A caller must have Discord's
|
|
69
|
+
Administrator permission or at least one approved role; role names are never
|
|
70
|
+
used for authorization. Every response is ephemeral and read-only:
|
|
71
|
+
|
|
72
|
+
- `/dev shards` reports bounded shard health, including the unsharded
|
|
73
|
+
compatibility baseline;
|
|
74
|
+
- `/dev bot` reports the bot build and bounded installed-module versions;
|
|
75
|
+
- `/dev proc` reports safe metrics for the process handling the command.
|
|
76
|
+
|
|
77
|
+
No `/dev` command exposes tokens, URLs, Redis keys, database details,
|
|
78
|
+
environment variables or process arguments. Future developer subcommands must
|
|
79
|
+
extend this guild-only `/dev` root and reuse the same authorization boundary.
|
|
80
|
+
Hosted operators with `operations.read` can use the Admin Operations shard
|
|
81
|
+
view instead.
|
|
82
|
+
|
|
83
|
+
For self-hosting, leave the default `off` unless the owner has sized the local
|
|
84
|
+
machine and database. `auto` with the default `local` scope is the supported
|
|
85
|
+
advanced local path and does not require Redis. Coordinated multi-host
|
|
86
|
+
self-hosting is outside the first release.
|
|
87
|
+
|
|
88
|
+
Changing the total shard count redistributes guild ownership. Treat it as a
|
|
89
|
+
generation change: drain the old generation before admitting the new one and
|
|
90
|
+
never run both as active owners. Shard-capable code does not authorize hosted
|
|
91
|
+
activation. Phase 10 production activation, replica scaling, or any Railway
|
|
92
|
+
variable change requires separate owner authorization; do not provision a new
|
|
93
|
+
Redis or coordinator service for sharding.
|
|
94
|
+
|
|
27
95
|
For packaged operation use `helyx start`. `helyx migrate` applies core database migrations without Discord, and read-only `helyx doctor` verifies environment, PostgreSQL migration history, and configured package metadata.
|
|
@@ -11,13 +11,47 @@ export interface CloudConnectorStatus {
|
|
|
11
11
|
lastConnectedAt?: Date;
|
|
12
12
|
lastErrorCode?: string;
|
|
13
13
|
}
|
|
14
|
-
export
|
|
14
|
+
export interface CloudDiscordSnapshot {
|
|
15
|
+
applicationId: string;
|
|
16
|
+
botUserId: string;
|
|
17
|
+
botUsername: string;
|
|
18
|
+
ownerUserIds: string[];
|
|
19
|
+
guilds: Array<{
|
|
20
|
+
guildId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
ownerUserId: string | null;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
export interface CloudDiscordDirectory {
|
|
26
|
+
snapshot(): Promise<CloudDiscordSnapshot>;
|
|
27
|
+
}
|
|
28
|
+
export interface CloudConnector {
|
|
29
|
+
setControlHandler(handler: BotControlHandler): void;
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
status(): CloudConnectorStatus;
|
|
32
|
+
getSupportConsent(): Promise<CloudConnectorConsentRecord>;
|
|
33
|
+
updateSupportConsent(input: {
|
|
34
|
+
supportInspectionEnabled: boolean;
|
|
35
|
+
supportDataClasses: string[];
|
|
36
|
+
diagnosticsEnabled: boolean;
|
|
37
|
+
}): Promise<CloudConnectorConsentRecord>;
|
|
38
|
+
enrol(rawToken: string, source: "startup" | "discord" | "file"): Promise<CloudConnectorStatus>;
|
|
39
|
+
disconnect(): Promise<void>;
|
|
40
|
+
rotate(): Promise<CloudConnectorStatus>;
|
|
41
|
+
stop(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare class ClientCloudDiscordDirectory implements CloudDiscordDirectory {
|
|
44
|
+
private readonly client;
|
|
45
|
+
constructor(client: Client);
|
|
46
|
+
snapshot(): Promise<CloudDiscordSnapshot>;
|
|
47
|
+
}
|
|
48
|
+
export declare class CloudConnectorController implements CloudConnector {
|
|
15
49
|
#private;
|
|
16
50
|
private readonly input;
|
|
17
51
|
constructor(input: {
|
|
18
52
|
environment: BotEnvironment;
|
|
19
53
|
database: DatabasePool;
|
|
20
|
-
|
|
54
|
+
discord: CloudDiscordDirectory;
|
|
21
55
|
modules: readonly ConfiguredModulePackage[];
|
|
22
56
|
coreVersion: string;
|
|
23
57
|
logger: Logger;
|
package/dist/cloud-connector.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLOUD_DEFAULT_REQUEST_TIMEOUT_MS, CLOUD_PROTOCOL_VERSION, ConnectionSequenceGuard, assertCloudMessageSize, cloudControlResponsePayloadSchema, connectedCloudMessageSchema, connectorAcceptedSchema, connectorChallengeSchema, connectorCredentialActionSchema, connectorHelloSchema, connectorRejectedSchema, generateConnectorKeyPair, signConnectorCredentialAction, signConnectorChallenge, } from "@helyx/control-protocol";
|
|
1
|
+
import { CLOUD_DEFAULT_REQUEST_TIMEOUT_MS, CLOUD_PROTOCOL_VERSION, ConnectionSequenceGuard, assertCloudHandshakeSize, assertCloudMessageSize, cloudControlResponsePayloadSchema, connectedCloudMessageSchema, connectorAcceptedSchema, connectorChallengeSchema, connectorCredentialActionSchema, connectorHelloSchema, connectorRejectedSchema, generateConnectorKeyPair, signConnectorCredentialAction, signConnectorChallenge, } from "@helyx/control-protocol";
|
|
2
2
|
import { CloudConnectorRepository, } from "@helyx/database";
|
|
3
3
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
4
4
|
import { readFile, rm } from "node:fs/promises";
|
|
@@ -42,6 +42,34 @@ const supportConsentInputSchema = z
|
|
|
42
42
|
diagnosticsEnabled: z.boolean(),
|
|
43
43
|
})
|
|
44
44
|
.strict();
|
|
45
|
+
export class ClientCloudDiscordDirectory {
|
|
46
|
+
client;
|
|
47
|
+
constructor(client) {
|
|
48
|
+
this.client = client;
|
|
49
|
+
}
|
|
50
|
+
async snapshot() {
|
|
51
|
+
const application = await this.client.application?.fetch();
|
|
52
|
+
const botUser = this.client.user;
|
|
53
|
+
if (!application || !botUser)
|
|
54
|
+
throw new Error("discord_not_ready");
|
|
55
|
+
const owner = application.owner;
|
|
56
|
+
return {
|
|
57
|
+
applicationId: application.id,
|
|
58
|
+
botUserId: botUser.id,
|
|
59
|
+
botUsername: botUser.username,
|
|
60
|
+
ownerUserIds: !owner
|
|
61
|
+
? []
|
|
62
|
+
: "members" in owner
|
|
63
|
+
? [...owner.members.keys()]
|
|
64
|
+
: [owner.id],
|
|
65
|
+
guilds: this.client.guilds.cache.map((guild) => ({
|
|
66
|
+
guildId: guild.id,
|
|
67
|
+
name: guild.name,
|
|
68
|
+
ownerUserId: guild.ownerId || null,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
45
73
|
export class CloudConnectorController {
|
|
46
74
|
input;
|
|
47
75
|
#repository;
|
|
@@ -143,9 +171,8 @@ export class CloudConnectorController {
|
|
|
143
171
|
throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
|
|
144
172
|
const token = normalizeEnrolmentToken(rawToken);
|
|
145
173
|
const keyPair = generateConnectorKeyPair();
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
if (!application || !botUser)
|
|
174
|
+
const discord = await this.input.discord.snapshot().catch(() => undefined);
|
|
175
|
+
if (!discord)
|
|
149
176
|
throw new CloudConnectorUserError("Discord must be connected before Cloud Connect enrolment");
|
|
150
177
|
let response;
|
|
151
178
|
try {
|
|
@@ -161,9 +188,9 @@ export class CloudConnectorController {
|
|
|
161
188
|
publicKeyFingerprint: keyPair.fingerprint,
|
|
162
189
|
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
163
190
|
coreVersion: this.input.coreVersion,
|
|
164
|
-
botApplicationId:
|
|
165
|
-
botUserId:
|
|
166
|
-
displayName:
|
|
191
|
+
botApplicationId: discord.applicationId,
|
|
192
|
+
botUserId: discord.botUserId,
|
|
193
|
+
displayName: discord.botUsername,
|
|
167
194
|
}),
|
|
168
195
|
signal: AbortSignal.timeout(CLOUD_DEFAULT_REQUEST_TIMEOUT_MS),
|
|
169
196
|
});
|
|
@@ -456,15 +483,7 @@ export class CloudConnectorController {
|
|
|
456
483
|
async #sendHello(socket, identity, privateKey, challenge) {
|
|
457
484
|
if (challenge.installationId !== identity.installationId)
|
|
458
485
|
throw new Error("challenge_installation_mismatch");
|
|
459
|
-
const
|
|
460
|
-
const botUser = this.input.client.user;
|
|
461
|
-
if (!application || !botUser)
|
|
462
|
-
throw new Error("discord_not_ready");
|
|
463
|
-
const guilds = this.input.client.guilds.cache.map((guild) => ({
|
|
464
|
-
guildId: guild.id,
|
|
465
|
-
name: guild.name,
|
|
466
|
-
ownerUserId: guild.ownerId || null,
|
|
467
|
-
}));
|
|
486
|
+
const discord = await this.input.discord.snapshot();
|
|
468
487
|
const capabilities = connectorCapabilities();
|
|
469
488
|
const hello = connectorHelloSchema.parse({
|
|
470
489
|
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
@@ -476,8 +495,8 @@ export class CloudConnectorController {
|
|
|
476
495
|
challengeId: challenge.challengeId,
|
|
477
496
|
signature: signConnectorChallenge(privateKey, challenge),
|
|
478
497
|
coreVersion: this.input.coreVersion,
|
|
479
|
-
botApplicationId:
|
|
480
|
-
botUserId:
|
|
498
|
+
botApplicationId: discord.applicationId,
|
|
499
|
+
botUserId: discord.botUserId,
|
|
481
500
|
capabilities,
|
|
482
501
|
modules: this.input.modules.map(({ specifier, definition }) => ({
|
|
483
502
|
id: definition.manifest.id,
|
|
@@ -486,9 +505,11 @@ export class CloudConnectorController {
|
|
|
486
505
|
manifestSchemaVersion: 1,
|
|
487
506
|
configurationSchemaVersion: definition.configuration?.schemaVersion ?? 1,
|
|
488
507
|
})),
|
|
489
|
-
guilds,
|
|
508
|
+
guilds: discord.guilds,
|
|
490
509
|
});
|
|
491
|
-
|
|
510
|
+
const encodedHello = JSON.stringify(hello);
|
|
511
|
+
assertCloudHandshakeSize(encodedHello);
|
|
512
|
+
socket.send(encodedHello);
|
|
492
513
|
}
|
|
493
514
|
async #dispatch(request, correlationId) {
|
|
494
515
|
try {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { type ModuleDefinition } from "@helyx/sdk";
|
|
2
2
|
import type { Client } from "discord.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type CloudConnector, type CloudDiscordDirectory } from "./cloud-connector.js";
|
|
4
4
|
export declare class DeploymentOwnerAuthorizer {
|
|
5
5
|
#private;
|
|
6
|
-
constructor(
|
|
6
|
+
constructor(directory: CloudDiscordDirectory | Client, configuredOwnerUserIds: readonly string[]);
|
|
7
7
|
isOwner(userId: string): boolean;
|
|
8
8
|
refresh(): Promise<void>;
|
|
9
9
|
}
|
|
10
10
|
export declare function createCloudManagementModule(input: {
|
|
11
|
-
connector:
|
|
11
|
+
connector: CloudConnector;
|
|
12
|
+
discord?: CloudDiscordDirectory;
|
|
12
13
|
client: Client;
|
|
13
14
|
configuredOwnerUserIds: readonly string[];
|
|
15
|
+
periodicOwnerRefresh?: boolean;
|
|
14
16
|
}): ModuleDefinition;
|
|
15
17
|
//# sourceMappingURL=cloud-management-module.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineModule, } from "@helyx/sdk";
|
|
2
2
|
import { Events } from "discord.js";
|
|
3
|
-
import { CloudConnectorUserError, } from "./cloud-connector.js";
|
|
3
|
+
import { ClientCloudDiscordDirectory, CloudConnectorUserError, } from "./cloud-connector.js";
|
|
4
4
|
const CONNECT_MODAL_ID = "helyx.cloud.connect";
|
|
5
5
|
const CONNECT_CODE_FIELD_ID = "helyx.cloud.connect.code";
|
|
6
6
|
const DISCONNECT_MODAL_ID = "helyx.cloud.disconnect";
|
|
@@ -16,11 +16,17 @@ const SUCCESS = 0x16a34a;
|
|
|
16
16
|
const DANGER = 0xdc2626;
|
|
17
17
|
const OWNER_REFRESH_INTERVAL_MS = 15 * 60 * 1_000;
|
|
18
18
|
export class DeploymentOwnerAuthorizer {
|
|
19
|
+
#directory;
|
|
19
20
|
#client;
|
|
20
21
|
#configuredOwnerUserIds;
|
|
21
22
|
#applicationOwnerUserIds = new Set();
|
|
22
|
-
constructor(
|
|
23
|
-
|
|
23
|
+
constructor(directory, configuredOwnerUserIds) {
|
|
24
|
+
if ("snapshot" in directory)
|
|
25
|
+
this.#directory = directory;
|
|
26
|
+
else {
|
|
27
|
+
this.#client = directory;
|
|
28
|
+
this.#applicationOwnerUserIds = discordOwnerIds(directory.application?.owner);
|
|
29
|
+
}
|
|
24
30
|
this.#configuredOwnerUserIds = new Set(configuredOwnerUserIds);
|
|
25
31
|
}
|
|
26
32
|
isOwner(userId) {
|
|
@@ -28,23 +34,24 @@ export class DeploymentOwnerAuthorizer {
|
|
|
28
34
|
this.#applicationOwnerUserIds.has(userId));
|
|
29
35
|
}
|
|
30
36
|
async refresh() {
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
if (this.#directory) {
|
|
38
|
+
const snapshot = await this.#directory.snapshot();
|
|
39
|
+
this.#applicationOwnerUserIds = new Set(snapshot.ownerUserIds);
|
|
33
40
|
return;
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
this.#
|
|
37
|
-
}
|
|
38
|
-
#replaceApplicationOwners(owner) {
|
|
39
|
-
this.#applicationOwnerUserIds = !owner
|
|
40
|
-
? new Set()
|
|
41
|
-
: "members" in owner
|
|
42
|
-
? new Set(owner.members.keys())
|
|
43
|
-
: new Set([owner.id]);
|
|
41
|
+
}
|
|
42
|
+
const application = await this.#client?.application?.fetch();
|
|
43
|
+
this.#applicationOwnerUserIds = discordOwnerIds(application?.owner);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
function discordOwnerIds(owner) {
|
|
47
|
+
if (!owner)
|
|
48
|
+
return new Set();
|
|
49
|
+
if ("id" in owner)
|
|
50
|
+
return new Set([owner.id]);
|
|
51
|
+
return new Set([...owner.members.values()].map((member) => member.id));
|
|
52
|
+
}
|
|
46
53
|
export function createCloudManagementModule(input) {
|
|
47
|
-
const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.client, input.configuredOwnerUserIds);
|
|
54
|
+
const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.discord ?? new ClientCloudDiscordDirectory(input.client), input.configuredOwnerUserIds);
|
|
48
55
|
let ownerRefreshInterval;
|
|
49
56
|
let onClientReady;
|
|
50
57
|
const requireOwner = async (context) => {
|
|
@@ -455,8 +462,10 @@ export function createCloudManagementModule(input) {
|
|
|
455
462
|
};
|
|
456
463
|
onClientReady = () => {
|
|
457
464
|
refreshOwners();
|
|
458
|
-
|
|
459
|
-
|
|
465
|
+
if (input.periodicOwnerRefresh !== false) {
|
|
466
|
+
ownerRefreshInterval ??= setInterval(refreshOwners, OWNER_REFRESH_INTERVAL_MS);
|
|
467
|
+
ownerRefreshInterval.unref();
|
|
468
|
+
}
|
|
460
469
|
};
|
|
461
470
|
if (input.client.isReady())
|
|
462
471
|
onClientReady();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModuleDefinition } from "@helyx/sdk";
|
|
2
|
+
export interface CommandHashState {
|
|
3
|
+
value: unknown;
|
|
4
|
+
version: bigint;
|
|
5
|
+
}
|
|
6
|
+
export interface CommandHashStore {
|
|
7
|
+
get(key: string): Promise<CommandHashState | null>;
|
|
8
|
+
set(key: string, value: unknown, expectedVersion?: bigint | null): Promise<CommandHashState>;
|
|
9
|
+
}
|
|
10
|
+
export interface DiscordCommandRest {
|
|
11
|
+
put(route: `/${string}`, options: {
|
|
12
|
+
body: unknown;
|
|
13
|
+
}): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface CommandSyncResult {
|
|
16
|
+
global: "updated" | "unchanged";
|
|
17
|
+
guild: "updated" | "unchanged" | "not_configured";
|
|
18
|
+
globalCount: number;
|
|
19
|
+
guildCount: number;
|
|
20
|
+
globalHash: string;
|
|
21
|
+
guildHash?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function synchroniseApplicationCommands(input: {
|
|
24
|
+
applicationId: string;
|
|
25
|
+
token: string;
|
|
26
|
+
modules: readonly ModuleDefinition[];
|
|
27
|
+
internalGuildId?: string;
|
|
28
|
+
state: CommandHashStore;
|
|
29
|
+
rest?: DiscordCommandRest;
|
|
30
|
+
}): Promise<CommandSyncResult>;
|
|
31
|
+
export declare function commandDefinitionsHash(definitions: readonly unknown[]): string;
|
|
32
|
+
//# sourceMappingURL=command-sync.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { REST, Routes } from "discord.js";
|
|
3
|
+
import { buildApplicationCommandDefinitions } from "./interactions.js";
|
|
4
|
+
export async function synchroniseApplicationCommands(input) {
|
|
5
|
+
const definitions = buildApplicationCommandDefinitions(input.modules, input.internalGuildId);
|
|
6
|
+
definitions.global.sort((left, right) => left.name.localeCompare(right.name));
|
|
7
|
+
definitions.guild.sort((left, right) => left.name.localeCompare(right.name));
|
|
8
|
+
const rest = input.rest ?? new REST({ version: "10" }).setToken(input.token);
|
|
9
|
+
const globalKey = `commands:${input.applicationId}:global`;
|
|
10
|
+
const globalHash = commandDefinitionsHash(definitions.global);
|
|
11
|
+
const global = await synchroniseCollection({
|
|
12
|
+
key: globalKey,
|
|
13
|
+
hash: globalHash,
|
|
14
|
+
definitions: definitions.global,
|
|
15
|
+
state: input.state,
|
|
16
|
+
update: () => rest.put(Routes.applicationCommands(input.applicationId), {
|
|
17
|
+
body: definitions.global,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
if (!input.internalGuildId)
|
|
21
|
+
return {
|
|
22
|
+
global,
|
|
23
|
+
guild: "not_configured",
|
|
24
|
+
globalCount: definitions.global.length,
|
|
25
|
+
guildCount: 0,
|
|
26
|
+
globalHash,
|
|
27
|
+
};
|
|
28
|
+
const guildKey = `commands:${input.applicationId}:guild:${input.internalGuildId}`;
|
|
29
|
+
const guildHash = commandDefinitionsHash(definitions.guild);
|
|
30
|
+
const guild = await synchroniseCollection({
|
|
31
|
+
key: guildKey,
|
|
32
|
+
hash: guildHash,
|
|
33
|
+
definitions: definitions.guild,
|
|
34
|
+
state: input.state,
|
|
35
|
+
update: () => rest.put(Routes.applicationGuildCommands(input.applicationId, input.internalGuildId), { body: definitions.guild }),
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
global,
|
|
39
|
+
guild,
|
|
40
|
+
globalCount: definitions.global.length,
|
|
41
|
+
guildCount: definitions.guild.length,
|
|
42
|
+
globalHash,
|
|
43
|
+
guildHash,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function commandDefinitionsHash(definitions) {
|
|
47
|
+
return createHash("sha256").update(JSON.stringify(definitions)).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
async function synchroniseCollection(input) {
|
|
50
|
+
const current = await input.state.get(input.key);
|
|
51
|
+
if (isCommandHashState(current?.value, input.hash))
|
|
52
|
+
return "unchanged";
|
|
53
|
+
await input.update();
|
|
54
|
+
await input.state.set(input.key, {
|
|
55
|
+
hash: input.hash,
|
|
56
|
+
commandCount: input.definitions.length,
|
|
57
|
+
synchronisedAt: new Date().toISOString(),
|
|
58
|
+
}, current?.version ?? null);
|
|
59
|
+
return "updated";
|
|
60
|
+
}
|
|
61
|
+
function isCommandHashState(value, expectedHash) {
|
|
62
|
+
return (typeof value === "object" &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
"hash" in value &&
|
|
65
|
+
value.hash === expectedHash);
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=command-sync.js.map
|
package/dist/control-server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type DatabasePool } from "@helyx/database";
|
|
|
2
2
|
import { type DashboardActionResult, type MessageResponse, type ModuleDefinition, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
|
|
3
3
|
import { type Client } from "discord.js";
|
|
4
4
|
import { type FastifyInstance } from "fastify";
|
|
5
|
+
import { type ShardHealthSnapshot } from "./shard-contracts.js";
|
|
5
6
|
interface DashboardPermissionPolicy {
|
|
6
7
|
preset: "everyone" | "moderators" | "administrators" | "custom";
|
|
7
8
|
allowedRoleIds: string[];
|
|
@@ -161,6 +162,10 @@ export interface BotControlHandler {
|
|
|
161
162
|
cursor?: string;
|
|
162
163
|
}): Promise<PrivacyErasePage>;
|
|
163
164
|
}
|
|
165
|
+
export declare class BotControlTransportError extends Error {
|
|
166
|
+
readonly status: 400 | 404 | 409 | 502 | 503 | 504;
|
|
167
|
+
constructor(status: 400 | 404 | 409 | 502 | 503 | 504, message: string);
|
|
168
|
+
}
|
|
164
169
|
export declare class BotControlService implements BotControlHandler {
|
|
165
170
|
#private;
|
|
166
171
|
private readonly database;
|
|
@@ -236,6 +241,11 @@ export declare function createBotControlServer(input: {
|
|
|
236
241
|
logger?: boolean | {
|
|
237
242
|
level: string;
|
|
238
243
|
};
|
|
244
|
+
shardHealth?: () => Promise<ShardHealthSnapshot>;
|
|
239
245
|
}): Promise<FastifyInstance>;
|
|
246
|
+
export declare function mapBotControlError(error: unknown): {
|
|
247
|
+
status: 400 | 404 | 409;
|
|
248
|
+
message: string;
|
|
249
|
+
} | undefined;
|
|
240
250
|
export {};
|
|
241
251
|
//# sourceMappingURL=control-server.d.ts.map
|
package/dist/control-server.js
CHANGED
|
@@ -5,6 +5,7 @@ import Fastify, {} from "fastify";
|
|
|
5
5
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { z, ZodError } from "zod";
|
|
7
7
|
import { deleteChannelMessage as deleteDiscordChannelMessage, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
|
|
8
|
+
import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
|
|
8
9
|
const snowflake = z.string().regex(/^\d{17,20}$/u);
|
|
9
10
|
const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
|
|
10
11
|
const configurationValue = z.record(z.string(), z.union([
|
|
@@ -87,6 +88,14 @@ export class DiscordGuildResourceDirectory {
|
|
|
87
88
|
return deleteDiscordChannelMessage(this.client, channelId, messageId);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
91
|
+
export class BotControlTransportError extends Error {
|
|
92
|
+
status;
|
|
93
|
+
constructor(status, message) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.status = status;
|
|
96
|
+
this.name = "BotControlTransportError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
90
99
|
export class BotControlService {
|
|
91
100
|
database;
|
|
92
101
|
resources;
|
|
@@ -504,8 +513,62 @@ export async function createBotControlServer(input) {
|
|
|
504
513
|
if (!timingSafeEqual(expectedSecret, digest(supplied)))
|
|
505
514
|
return reply.code(401).send({ message: "Control authentication failed" });
|
|
506
515
|
});
|
|
507
|
-
|
|
508
|
-
|
|
516
|
+
async function readiness(reply) {
|
|
517
|
+
if (!input.shardHealth)
|
|
518
|
+
return { status: "ok" };
|
|
519
|
+
try {
|
|
520
|
+
const health = await input.shardHealth();
|
|
521
|
+
if (health.status === "healthy")
|
|
522
|
+
return { status: "ok" };
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// The public readiness route deliberately returns no internal detail.
|
|
526
|
+
}
|
|
527
|
+
return reply.code(503).send({ status: "unavailable" });
|
|
528
|
+
}
|
|
529
|
+
server.get("/health", (_request, reply) => readiness(reply));
|
|
530
|
+
server.get("/v1/health", (_request, reply) => readiness(reply));
|
|
531
|
+
server.get("/v1/shards/health", async (request, reply) => {
|
|
532
|
+
reply.header("Cache-Control", "no-store");
|
|
533
|
+
if (!input.shardHealth)
|
|
534
|
+
return reply.code(503).send({
|
|
535
|
+
message: "Shard health is unavailable while sharding is off",
|
|
536
|
+
});
|
|
537
|
+
const { cursor, limit } = z
|
|
538
|
+
.object({
|
|
539
|
+
cursor: z.coerce.number().int().min(0).max(4_095).default(0),
|
|
540
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
541
|
+
})
|
|
542
|
+
.strict()
|
|
543
|
+
.parse(request.query);
|
|
544
|
+
const health = await input.shardHealth();
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
const shards = health.shards.slice(cursor, cursor + limit);
|
|
547
|
+
const nextCursor = cursor + shards.length < health.shards.length
|
|
548
|
+
? String(cursor + shards.length)
|
|
549
|
+
: null;
|
|
550
|
+
return {
|
|
551
|
+
mode: health.mode,
|
|
552
|
+
configuredShardCount: health.configuredShardCount,
|
|
553
|
+
readyShardCount: health.readyShardCount,
|
|
554
|
+
observedAt: health.observedAt,
|
|
555
|
+
nextCursor,
|
|
556
|
+
shards: shards.map((shard) => ({
|
|
557
|
+
id: shard.shardId,
|
|
558
|
+
status: now - new Date(shard.observedAt).getTime() > SHARD_HEALTH_STALE_MS
|
|
559
|
+
? "stale"
|
|
560
|
+
: shard.status === "ready"
|
|
561
|
+
? "healthy"
|
|
562
|
+
: shard.status === "starting" || shard.status === "reconnecting"
|
|
563
|
+
? "starting"
|
|
564
|
+
: "degraded",
|
|
565
|
+
guildCount: shard.guildCount,
|
|
566
|
+
gatewayPingMs: shard.gatewayPingMs < 0 ? null : Math.round(shard.gatewayPingMs),
|
|
567
|
+
uptimeSeconds: Math.floor(shard.uptimeSeconds),
|
|
568
|
+
restartCount: shard.restartCount,
|
|
569
|
+
})),
|
|
570
|
+
};
|
|
571
|
+
});
|
|
509
572
|
server.get("/v1/privacy/providers", () => input.service.listPrivacyProviders());
|
|
510
573
|
server.post("/v1/privacy/capture", async (request) => {
|
|
511
574
|
const body = z
|
|
@@ -605,22 +668,11 @@ export async function createBotControlServer(input) {
|
|
|
605
668
|
return input.service.updateLoggingPolicy({ ...params, ...body });
|
|
606
669
|
});
|
|
607
670
|
server.setErrorHandler(async (error, request, reply) => {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
return reply.code(400).send({ message: error.message });
|
|
614
|
-
if (error instanceof DashboardActionValidationError)
|
|
615
|
-
return reply.code(400).send({ message: error.message });
|
|
616
|
-
if (error instanceof ModuleStateValidationError)
|
|
617
|
-
return reply.code(400).send({ message: error.message });
|
|
618
|
-
if (error instanceof BotControlNotFoundError)
|
|
619
|
-
return reply.code(404).send({ message: error.message });
|
|
620
|
-
if (error instanceof OptimisticConcurrencyError)
|
|
621
|
-
return reply.code(409).send({
|
|
622
|
-
message: "Configuration changed in another session. Reload and try again.",
|
|
623
|
-
});
|
|
671
|
+
const mapped = mapBotControlError(error);
|
|
672
|
+
if (mapped)
|
|
673
|
+
return reply.code(mapped.status).send({ message: mapped.message });
|
|
674
|
+
if (error instanceof BotControlTransportError)
|
|
675
|
+
return reply.code(error.status).send({ message: error.message });
|
|
624
676
|
const safeError = error instanceof Error
|
|
625
677
|
? { name: error.name, message: error.message }
|
|
626
678
|
: { name: "Error", message: "Unknown control failure" };
|
|
@@ -629,6 +681,22 @@ export async function createBotControlServer(input) {
|
|
|
629
681
|
});
|
|
630
682
|
return server;
|
|
631
683
|
}
|
|
684
|
+
export function mapBotControlError(error) {
|
|
685
|
+
if (error instanceof ZodError)
|
|
686
|
+
return { status: 400, message: "The control request was invalid" };
|
|
687
|
+
if (error instanceof BotControlValidationError ||
|
|
688
|
+
error instanceof DashboardActionValidationError ||
|
|
689
|
+
error instanceof ModuleStateValidationError)
|
|
690
|
+
return { status: 400, message: error.message };
|
|
691
|
+
if (error instanceof BotControlNotFoundError)
|
|
692
|
+
return { status: 404, message: error.message };
|
|
693
|
+
if (error instanceof OptimisticConcurrencyError)
|
|
694
|
+
return {
|
|
695
|
+
status: 409,
|
|
696
|
+
message: "Configuration changed in another session. Reload and try again.",
|
|
697
|
+
};
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
632
700
|
class BotControlValidationError extends Error {
|
|
633
701
|
}
|
|
634
702
|
class BotControlNotFoundError extends Error {
|
|
@@ -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
|