@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
|
@@ -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):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
146
|
+
const publicModules = [
|
|
131
147
|
managementModule,
|
|
132
148
|
permissionsModule,
|
|
133
149
|
...(cloudManagementModule ? [cloudManagementModule] : []),
|
|
134
150
|
...featureModules,
|
|
135
151
|
];
|
|
136
|
-
const
|
|
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,
|
|
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,
|
|
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,
|
|
159
|
-
const controlService = new BotControlService(database,
|
|
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)) {
|
package/dist/interactions.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommandContribution, MessageResponse, ModalDefinition, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
|
|
2
|
-
import { ModalBuilder, type Client, type InteractionReplyOptions, type InteractionUpdateOptions } from "discord.js";
|
|
2
|
+
import { ModalBuilder, type ChatInputCommandInteraction, type Client, type InteractionReplyOptions, type InteractionUpdateOptions } from "discord.js";
|
|
3
3
|
export interface InteractionLogger {
|
|
4
4
|
info(fields: Record<string, unknown>, message: string): void;
|
|
5
5
|
warn(fields: Record<string, unknown>, message: string): void;
|
|
@@ -8,8 +8,14 @@ export interface InteractionLogger {
|
|
|
8
8
|
export interface CommandRegistration {
|
|
9
9
|
internalGuildId?: string;
|
|
10
10
|
prepare?(client: Client<true>): Promise<void>;
|
|
11
|
+
synchronise?: boolean;
|
|
12
|
+
selfAuthorizingGuildModuleIds?: ReadonlySet<string>;
|
|
11
13
|
}
|
|
12
14
|
export declare function installInteractions(client: Client, modules: readonly ModuleDefinition[], registration: CommandRegistration, logger: InteractionLogger, services: ServiceAccess): Promise<void>;
|
|
15
|
+
export declare function buildApplicationCommandDefinitions(modules: readonly ModuleDefinition[], internalGuildId?: string): {
|
|
16
|
+
global: ReturnType<typeof toCommandDefinition>[];
|
|
17
|
+
guild: ReturnType<typeof toCommandDefinition>[];
|
|
18
|
+
};
|
|
13
19
|
export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null): CommandContribution["execute"];
|
|
14
20
|
export declare function toCommandDefinition(registered: {
|
|
15
21
|
contribution: CommandContribution;
|
|
@@ -20,6 +26,7 @@ export declare function sendToChannel(client: Client, channelId: string, respons
|
|
|
20
26
|
}>;
|
|
21
27
|
export declare function deleteChannelMessage(client: Client, channelId: string, messageId: string): Promise<void>;
|
|
22
28
|
export declare function editChannelMessage(client: Client, channelId: string, messageId: string, response: MessageResponse): Promise<void>;
|
|
29
|
+
export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean): Promise<boolean>;
|
|
23
30
|
export declare function normalizeDiscordPermission(permission: string): string;
|
|
24
31
|
export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
|
|
25
32
|
export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
|