@helyx/bot 0.4.9 → 0.4.13
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 +66 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +43 -3
- package/dist/control-server.js +228 -18
- package/dist/events.d.ts +92 -1
- package/dist/events.js +1483 -5
- package/dist/guilds.d.ts +20 -1
- package/dist/guilds.js +149 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -23
- package/dist/interactions.js +101 -22
- package/dist/management-module.d.ts +1 -1
- package/dist/management-module.js +42 -10
- package/dist/message-classification.d.ts +5 -0
- package/dist/message-classification.js +75 -0
- package/dist/modules.js +9 -0
- package/dist/numeric-write-behind-proxy.d.ts +62 -0
- package/dist/numeric-write-behind-proxy.js +313 -0
- package/dist/numeric-write-behind-redis.d.ts +123 -0
- package/dist/numeric-write-behind-redis.js +1281 -0
- package/dist/numeric-write-behind.d.ts +5 -0
- package/dist/numeric-write-behind.js +37 -0
- package/dist/shard-contracts.d.ts +810 -0
- package/dist/shard-contracts.js +292 -3
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +42 -0
- package/dist/shard-supervisor.d.ts +4 -1
- package/dist/shard-supervisor.js +262 -7
- package/dist/shard-worker.d.ts +7 -0
- package/dist/shard-worker.js +64 -10
- package/dist/verification-service.d.ts +50 -0
- package/dist/verification-service.js +166 -0
- package/package.json +10 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add the hosted-only Stage 11 Verification foundation: bounded signed challenge
|
|
8
|
+
contracts, restricted persistence and privacy traversal, shard-safe bot
|
|
9
|
+
delivery, delegated investigation permissions, verifier configuration and
|
|
10
|
+
health, and explicit exclusion from every self-hosted composition.
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @helyx/control-protocol@0.3.0
|
|
13
|
+
- @helyx/module-manifest@0.6.0
|
|
14
|
+
- @helyx/config@0.5.0
|
|
15
|
+
- @helyx/database@0.6.0
|
|
16
|
+
- @helyx/sdk@0.5.0
|
|
17
|
+
- @helyx/platform@0.4.0
|
|
18
|
+
- @helyx/core@0.1.12
|
|
19
|
+
- @helyx/framework@0.1.12
|
|
20
|
+
|
|
21
|
+
## 0.4.12
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Repair hosted guild presence after removal, restart and shard reassignment with
|
|
26
|
+
bounded fenced reconciliation and customer-safe dashboard states.
|
|
27
|
+
- Exit with failure after an unexpected coordinated-shard lease shutdown so the
|
|
28
|
+
service orchestrator replaces the stopped supervisor automatically.
|
|
29
|
+
- Retry one transient lease-renewal transport failure within the existing
|
|
30
|
+
fencing window while still failing closed on an actual ownership mismatch.
|
|
31
|
+
- Updated dependencies
|
|
32
|
+
- @helyx/config@0.4.4
|
|
33
|
+
- @helyx/database@0.5.1
|
|
34
|
+
- @helyx/platform@0.3.1
|
|
35
|
+
|
|
36
|
+
## 0.4.11
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- Add the reusable Stage 8 Levels foundations: privacy-bounded guild message
|
|
41
|
+
events with optional transient type classification, optional Discord-user
|
|
42
|
+
command options, multiple channel settings, bounded ranked numeric records,
|
|
43
|
+
idempotent numeric mutation receipts, and core-owned write-behind accumulation
|
|
44
|
+
across local and coordinated shard compositions.
|
|
45
|
+
- Updated dependencies
|
|
46
|
+
- @helyx/database@0.5.0
|
|
47
|
+
- @helyx/module-manifest@0.5.0
|
|
48
|
+
- @helyx/control-protocol@0.2.0
|
|
49
|
+
- @helyx/sdk@0.4.0
|
|
50
|
+
- @helyx/platform@0.3.0
|
|
51
|
+
- @helyx/core@0.1.11
|
|
52
|
+
- @helyx/framework@0.1.11
|
|
53
|
+
|
|
54
|
+
## 0.4.10
|
|
55
|
+
|
|
56
|
+
### Patch Changes
|
|
57
|
+
|
|
58
|
+
- Add the shared session, Discord event, resource inspection, enforcement,
|
|
59
|
+
privacy and hosted/self-hosted composition support required by the Account Gate
|
|
60
|
+
Testing module.
|
|
61
|
+
- Updated dependencies
|
|
62
|
+
- @helyx/config@0.4.3
|
|
63
|
+
- @helyx/database@0.4.4
|
|
64
|
+
- @helyx/sdk@0.3.5
|
|
65
|
+
- @helyx/platform@0.2.8
|
|
66
|
+
- @helyx/core@0.1.10
|
|
67
|
+
- @helyx/framework@0.1.10
|
|
68
|
+
|
|
3
69
|
## 0.4.9
|
|
4
70
|
|
|
5
71
|
### Patch Changes
|
|
@@ -146,11 +146,12 @@ export class CloudControlDispatcher {
|
|
|
146
146
|
break;
|
|
147
147
|
}
|
|
148
148
|
case "guild.module.resource.action.execute.v1": {
|
|
149
|
-
const input = parseOperationInput(request.operation, request.input);
|
|
149
|
+
const { actorAuthenticatedAt, ...input } = parseOperationInput(request.operation, request.input);
|
|
150
150
|
result = await this.control.executeManagedResourceAction({
|
|
151
151
|
...input,
|
|
152
152
|
actorUserId: requiredActor(actorUserId),
|
|
153
153
|
correlationId,
|
|
154
|
+
...(actorAuthenticatedAt ? { actorAuthenticatedAt } : {}),
|
|
154
155
|
});
|
|
155
156
|
break;
|
|
156
157
|
}
|
package/dist/control-server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type DatabasePool } from "@helyx/database";
|
|
1
|
+
import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
|
|
2
2
|
import { type DashboardActionResult, type ManagedResourceCreationIntent, type ManagedResourceDetail, type ManagedResourcePage, type ManagedResourceRelationPage, type ManagedResourceValue, type MessageResponse, type ModuleDefinition, type PermissionActor, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
|
|
3
|
+
import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
|
|
3
4
|
import { type Client } from "discord.js";
|
|
4
5
|
import { type FastifyInstance } from "fastify";
|
|
5
6
|
import { type ShardHealthSnapshot } from "./shard-contracts.js";
|
|
@@ -63,7 +64,7 @@ export interface BotControlGuildResources {
|
|
|
63
64
|
channels: Array<{
|
|
64
65
|
id: string;
|
|
65
66
|
name: string;
|
|
66
|
-
type: "guild_text" | "guild_announcement";
|
|
67
|
+
type: "guild_text" | "guild_announcement" | "guild_category";
|
|
67
68
|
}>;
|
|
68
69
|
roles: Array<{
|
|
69
70
|
id: string;
|
|
@@ -71,7 +72,14 @@ export interface BotControlGuildResources {
|
|
|
71
72
|
colour: string;
|
|
72
73
|
}>;
|
|
73
74
|
}
|
|
75
|
+
export type BotGuildPresenceState = "installed" | "not_installed" | "unknown" | "unavailable";
|
|
76
|
+
export interface BotGuildPresenceObservation {
|
|
77
|
+
guildId: string;
|
|
78
|
+
state: BotGuildPresenceState;
|
|
79
|
+
observedAt: string;
|
|
80
|
+
}
|
|
74
81
|
export interface BotControlGuildResourceDirectory {
|
|
82
|
+
listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
|
|
75
83
|
getGuildResources(guildId: string): Promise<BotControlGuildResources>;
|
|
76
84
|
sendToChannel(channelId: string, response: MessageResponse): Promise<{
|
|
77
85
|
messageId: string;
|
|
@@ -85,6 +93,7 @@ export interface BotControlGuildResourceDirectory {
|
|
|
85
93
|
export declare class DiscordGuildResourceDirectory implements BotControlGuildResourceDirectory {
|
|
86
94
|
private readonly client;
|
|
87
95
|
constructor(client: Client);
|
|
96
|
+
listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
|
|
88
97
|
getGuildResources(guildId: string): Promise<BotControlGuildResources>;
|
|
89
98
|
sendToChannel(channelId: string, response: MessageResponse): Promise<{
|
|
90
99
|
messageId: string;
|
|
@@ -104,6 +113,9 @@ export interface SupportNotificationInput {
|
|
|
104
113
|
source: "dashboard_bug" | "dashboard_support" | "dashboard_security" | "discord_command";
|
|
105
114
|
}
|
|
106
115
|
export interface BotControlState {
|
|
116
|
+
discordCapabilities?: {
|
|
117
|
+
messageContent: boolean;
|
|
118
|
+
};
|
|
107
119
|
channels: BotControlGuildResources["channels"];
|
|
108
120
|
roles: BotControlGuildResources["roles"];
|
|
109
121
|
modules: DashboardModuleDefinition[];
|
|
@@ -117,7 +129,18 @@ export interface BotControlState {
|
|
|
117
129
|
}
|
|
118
130
|
export interface BotControlHandler {
|
|
119
131
|
sendSupportNotification(input: SupportNotificationInput): Promise<void>;
|
|
132
|
+
listGuildPresence(input: {
|
|
133
|
+
guildIds: readonly string[];
|
|
134
|
+
correlationId: string;
|
|
135
|
+
}): Promise<BotGuildPresenceObservation[]>;
|
|
120
136
|
getGuildState(guildId: string): Promise<BotControlState>;
|
|
137
|
+
canAccessManagedResource(input: {
|
|
138
|
+
guildId: string;
|
|
139
|
+
moduleId: string;
|
|
140
|
+
resourceType: string;
|
|
141
|
+
operation: "list" | "read";
|
|
142
|
+
actorUserId: string;
|
|
143
|
+
}): Promise<boolean>;
|
|
121
144
|
setModuleStatus(input: {
|
|
122
145
|
guildId: string;
|
|
123
146
|
moduleId: string;
|
|
@@ -237,7 +260,9 @@ export interface BotControlHandler {
|
|
|
237
260
|
value: Readonly<Record<string, ManagedResourceValue>>;
|
|
238
261
|
actorUserId: string;
|
|
239
262
|
correlationId: string;
|
|
263
|
+
actorAuthenticatedAt?: string;
|
|
240
264
|
}): Promise<ManagedResourceDetail | null>;
|
|
265
|
+
acceptVerificationResult(envelope: SignedAssessmentEnvelope): Promise<ActionReceipt>;
|
|
241
266
|
listPrivacyProviders(): Promise<readonly {
|
|
242
267
|
id: string;
|
|
243
268
|
version: number;
|
|
@@ -269,11 +294,18 @@ export declare class BotControlService implements BotControlHandler {
|
|
|
269
294
|
private readonly database;
|
|
270
295
|
private readonly resources;
|
|
271
296
|
private readonly supportNotifications?;
|
|
297
|
+
private readonly presenceOwnership?;
|
|
298
|
+
private readonly presenceShardCount?;
|
|
272
299
|
constructor(database: DatabasePool, modules: readonly ModuleDefinition[], resources: BotControlGuildResourceDirectory, services: ServiceAccess, privacyExecutor?: PrivacyProviderExecutor, supportNotifications?: {
|
|
273
300
|
guildId: string;
|
|
274
301
|
channelId: string;
|
|
275
|
-
} | undefined);
|
|
302
|
+
} | undefined, presenceOwnership?: GuildShardOwnership | undefined, presenceShardCount?: number | undefined);
|
|
303
|
+
listGuildPresence(input: {
|
|
304
|
+
guildIds: readonly string[];
|
|
305
|
+
correlationId: string;
|
|
306
|
+
}): Promise<BotGuildPresenceObservation[]>;
|
|
276
307
|
sendSupportNotification(input: SupportNotificationInput): Promise<void>;
|
|
308
|
+
acceptVerificationResult(envelopeInput: SignedAssessmentEnvelope): Promise<ActionReceipt>;
|
|
277
309
|
listPrivacyProviders(): Promise<readonly {
|
|
278
310
|
id: string;
|
|
279
311
|
version: number;
|
|
@@ -296,6 +328,13 @@ export declare class BotControlService implements BotControlHandler {
|
|
|
296
328
|
cursor?: string;
|
|
297
329
|
}): Promise<PrivacyErasePage>;
|
|
298
330
|
getGuildState(guildId: string): Promise<BotControlState>;
|
|
331
|
+
canAccessManagedResource(input: {
|
|
332
|
+
guildId: string;
|
|
333
|
+
moduleId: string;
|
|
334
|
+
resourceType: string;
|
|
335
|
+
operation: "list" | "read";
|
|
336
|
+
actorUserId: string;
|
|
337
|
+
}): Promise<boolean>;
|
|
299
338
|
setModuleStatus(input: {
|
|
300
339
|
guildId: string;
|
|
301
340
|
moduleId: string;
|
|
@@ -415,6 +454,7 @@ export declare class BotControlService implements BotControlHandler {
|
|
|
415
454
|
value: Readonly<Record<string, ManagedResourceValue>>;
|
|
416
455
|
actorUserId: string;
|
|
417
456
|
correlationId: string;
|
|
457
|
+
actorAuthenticatedAt?: string;
|
|
418
458
|
}): Promise<ManagedResourceDetail | null>;
|
|
419
459
|
}
|
|
420
460
|
export declare function createBotControlServer(input: {
|
package/dist/control-server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AuditRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
|
|
2
2
|
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
|
|
3
|
+
import { actionReceiptSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
|
|
3
4
|
import { canUseCommand } from "@helyx/core";
|
|
4
5
|
import { ChannelType, PermissionFlagsBits } from "discord.js";
|
|
5
6
|
import Fastify, {} from "fastify";
|
|
@@ -9,6 +10,7 @@ import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteCh
|
|
|
9
10
|
import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
|
|
10
11
|
const snowflake = z.string().regex(/^\d{17,20}$/u);
|
|
11
12
|
const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
|
|
13
|
+
const MAX_DISCORD_SETTING_SELECTIONS = 25;
|
|
12
14
|
const configurationValue = z.record(z.string(), z.union([
|
|
13
15
|
z.null(),
|
|
14
16
|
z.string(),
|
|
@@ -48,7 +50,7 @@ const resourceCursor = z.string().min(1).max(2_048);
|
|
|
48
50
|
const resourceActionId = z.string().regex(/^[a-z][a-z0-9_-]*$/u);
|
|
49
51
|
const managedResourceFilters = z
|
|
50
52
|
.record(z.string().regex(/^[a-z][a-zA-Z0-9_]*$/u), z.string().trim().min(1).max(100))
|
|
51
|
-
.refine((value) => Object.keys(value).length <=
|
|
53
|
+
.refine((value) => Object.keys(value).length <= 8);
|
|
52
54
|
const managedResourceValue = z.custom((value) => isBoundedManagedResourceValue(value), "Managed resource value exceeds the transport limits");
|
|
53
55
|
const managedResourceObject = z
|
|
54
56
|
.record(z.string().min(1).max(100), managedResourceValue)
|
|
@@ -97,6 +99,24 @@ export class DiscordGuildResourceDirectory {
|
|
|
97
99
|
constructor(client) {
|
|
98
100
|
this.client = client;
|
|
99
101
|
}
|
|
102
|
+
listGuildPresence(guildIds) {
|
|
103
|
+
const observedAt = new Date().toISOString();
|
|
104
|
+
const ready = this.client.isReady();
|
|
105
|
+
return Promise.resolve(guildIds.map((guildId) => {
|
|
106
|
+
const guild = this.client.guilds.cache.get(guildId);
|
|
107
|
+
return {
|
|
108
|
+
guildId,
|
|
109
|
+
state: !ready
|
|
110
|
+
? "unknown"
|
|
111
|
+
: !guild
|
|
112
|
+
? "not_installed"
|
|
113
|
+
: guild.available
|
|
114
|
+
? "installed"
|
|
115
|
+
: "unavailable",
|
|
116
|
+
observedAt,
|
|
117
|
+
};
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
100
120
|
async getGuildResources(guildId) {
|
|
101
121
|
try {
|
|
102
122
|
const guild = await this.client.guilds.fetch(guildId);
|
|
@@ -107,13 +127,16 @@ export class DiscordGuildResourceDirectory {
|
|
|
107
127
|
return {
|
|
108
128
|
channels: [...channels.values()]
|
|
109
129
|
.filter((channel) => channel?.type === ChannelType.GuildText ||
|
|
110
|
-
channel?.type === ChannelType.GuildAnnouncement
|
|
130
|
+
channel?.type === ChannelType.GuildAnnouncement ||
|
|
131
|
+
channel?.type === ChannelType.GuildCategory)
|
|
111
132
|
.map((channel) => ({
|
|
112
133
|
id: channel.id,
|
|
113
134
|
name: channel.name,
|
|
114
135
|
type: channel.type === ChannelType.GuildAnnouncement
|
|
115
136
|
? "guild_announcement"
|
|
116
|
-
:
|
|
137
|
+
: channel.type === ChannelType.GuildCategory
|
|
138
|
+
? "guild_category"
|
|
139
|
+
: "guild_text",
|
|
117
140
|
}))
|
|
118
141
|
.sort((left, right) => left.name.localeCompare(right.name)),
|
|
119
142
|
roles: [...roles.values()]
|
|
@@ -200,6 +223,8 @@ export class BotControlService {
|
|
|
200
223
|
database;
|
|
201
224
|
resources;
|
|
202
225
|
supportNotifications;
|
|
226
|
+
presenceOwnership;
|
|
227
|
+
presenceShardCount;
|
|
203
228
|
#modules;
|
|
204
229
|
#dashboardModules;
|
|
205
230
|
#configurations;
|
|
@@ -207,10 +232,12 @@ export class BotControlService {
|
|
|
207
232
|
#permissions;
|
|
208
233
|
#services;
|
|
209
234
|
#privacyProviders;
|
|
210
|
-
constructor(database, modules, resources, services, privacyExecutor, supportNotifications) {
|
|
235
|
+
constructor(database, modules, resources, services, privacyExecutor, supportNotifications, presenceOwnership, presenceShardCount) {
|
|
211
236
|
this.database = database;
|
|
212
237
|
this.resources = resources;
|
|
213
238
|
this.supportNotifications = supportNotifications;
|
|
239
|
+
this.presenceOwnership = presenceOwnership;
|
|
240
|
+
this.presenceShardCount = presenceShardCount;
|
|
214
241
|
this.#modules = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
215
242
|
this.#dashboardModules = modules
|
|
216
243
|
.filter((module) => module.manifest.dashboard !== undefined)
|
|
@@ -223,11 +250,45 @@ export class BotControlService {
|
|
|
223
250
|
? modules
|
|
224
251
|
.filter((module) => module.privacy !== undefined)
|
|
225
252
|
.map((module) => {
|
|
226
|
-
const provider = module.privacy.create(privacyExecutor);
|
|
253
|
+
const provider = module.privacy.create(privacyExecutor, this.#services);
|
|
227
254
|
return [provider.id, provider];
|
|
228
255
|
})
|
|
229
256
|
: []);
|
|
230
257
|
}
|
|
258
|
+
async listGuildPresence(input) {
|
|
259
|
+
const guildIds = [...new Set(input.guildIds)];
|
|
260
|
+
if (guildIds.length > 200)
|
|
261
|
+
throw new BotControlValidationError("Guild presence requests cannot exceed 200 guilds");
|
|
262
|
+
const observations = await this.resources.listGuildPresence(guildIds);
|
|
263
|
+
const returned = new Set(observations.map(({ guildId }) => guildId));
|
|
264
|
+
const complete = guildIds.map((guildId) => observations.find((item) => item.guildId === guildId) ?? {
|
|
265
|
+
guildId,
|
|
266
|
+
state: "unknown",
|
|
267
|
+
observedAt: new Date().toISOString(),
|
|
268
|
+
});
|
|
269
|
+
if (observations.some(({ guildId }) => !guildIds.includes(guildId)) ||
|
|
270
|
+
returned.size !== observations.length)
|
|
271
|
+
throw new BotControlTransportError(502, "The guild presence response was invalid");
|
|
272
|
+
const absent = complete
|
|
273
|
+
.filter(({ state }) => state === "not_installed")
|
|
274
|
+
.map(({ guildId }) => guildId);
|
|
275
|
+
if (absent.length > 0) {
|
|
276
|
+
const repository = new InstallationRepository(this.database);
|
|
277
|
+
if (this.presenceOwnership)
|
|
278
|
+
await repository.adoptGuildsForReconciliation({
|
|
279
|
+
guildIds: absent,
|
|
280
|
+
ownership: this.presenceOwnership,
|
|
281
|
+
shardCount: this.presenceShardCount,
|
|
282
|
+
});
|
|
283
|
+
await repository.markGuildsRemovedForReconciliation({
|
|
284
|
+
guildIds: absent,
|
|
285
|
+
...(this.presenceOwnership
|
|
286
|
+
? { ownership: this.presenceOwnership }
|
|
287
|
+
: {}),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return complete;
|
|
291
|
+
}
|
|
231
292
|
async sendSupportNotification(input) {
|
|
232
293
|
if (!this.supportNotifications)
|
|
233
294
|
throw new BotControlNotFoundError("The support notification channel is not configured");
|
|
@@ -262,6 +323,15 @@ export class BotControlService {
|
|
|
262
323
|
},
|
|
263
324
|
}, input.notificationId);
|
|
264
325
|
}
|
|
326
|
+
acceptVerificationResult(envelopeInput) {
|
|
327
|
+
if (!this.#services.has(HELYX_SERVICE_NAMES.verification))
|
|
328
|
+
throw new BotControlTransportError(503, "Verification result handling is unavailable");
|
|
329
|
+
const envelope = signedAssessmentEnvelopeSchema.parse(envelopeInput);
|
|
330
|
+
return this.#services
|
|
331
|
+
.get(HELYX_SERVICE_NAMES.verification)
|
|
332
|
+
.acceptResult(envelope)
|
|
333
|
+
.then((receipt) => actionReceiptSchema.parse(receipt));
|
|
334
|
+
}
|
|
265
335
|
listPrivacyProviders() {
|
|
266
336
|
return Promise.resolve([...this.#privacyProviders.values()]
|
|
267
337
|
.map((provider) => ({
|
|
@@ -290,10 +360,11 @@ export class BotControlService {
|
|
|
290
360
|
});
|
|
291
361
|
}
|
|
292
362
|
async getGuildState(guildId) {
|
|
293
|
-
const [resources, installedModules, storedLoggingPolicies] = await Promise.all([
|
|
363
|
+
const [resources, installedModules, storedLoggingPolicies, runtime] = await Promise.all([
|
|
294
364
|
this.resources.getGuildResources(guildId),
|
|
295
365
|
new InstallationRepository(this.database).listModules(guildId),
|
|
296
366
|
new ModuleLoggingPolicyRepository(this.database).listForGuild(guildId),
|
|
367
|
+
this.#inspectRuntimeCapabilities(),
|
|
297
368
|
]);
|
|
298
369
|
const installed = new Map(installedModules.map((module) => [module.moduleId, module]));
|
|
299
370
|
const moduleStatuses = {};
|
|
@@ -332,6 +403,9 @@ export class BotControlService {
|
|
|
332
403
|
});
|
|
333
404
|
}));
|
|
334
405
|
return {
|
|
406
|
+
discordCapabilities: {
|
|
407
|
+
messageContent: runtime.messageContent,
|
|
408
|
+
},
|
|
335
409
|
...resources,
|
|
336
410
|
modules: structuredClone(this.#dashboardModules),
|
|
337
411
|
moduleStatuses,
|
|
@@ -340,9 +414,42 @@ export class BotControlService {
|
|
|
340
414
|
logging,
|
|
341
415
|
};
|
|
342
416
|
}
|
|
417
|
+
async canAccessManagedResource(input) {
|
|
418
|
+
try {
|
|
419
|
+
const { module } = await this.#prepareManagedResource(input);
|
|
420
|
+
this.#requireManagedResourceOperation(module, input.resourceType, input.operation);
|
|
421
|
+
await this.#authorizeAction(input.guildId, module, input.actorUserId, managedResourcePermissionId(module, input.resourceType, input.operation));
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (error instanceof BotControlForbiddenError)
|
|
426
|
+
return false;
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async #inspectRuntimeCapabilities() {
|
|
431
|
+
if (!this.#services.has(HELYX_SERVICE_NAMES.discordResources))
|
|
432
|
+
return { messageContent: false };
|
|
433
|
+
const inspector = this.#services.get(HELYX_SERVICE_NAMES.discordResources);
|
|
434
|
+
if (typeof inspector.inspectRuntimeCapabilities !== "function")
|
|
435
|
+
return { messageContent: false };
|
|
436
|
+
const runtime = await inspector.inspectRuntimeCapabilities();
|
|
437
|
+
return { messageContent: runtime.messageContent };
|
|
438
|
+
}
|
|
343
439
|
async setModuleStatus(input) {
|
|
344
|
-
this.#requireDashboardModule(input.moduleId);
|
|
345
|
-
|
|
440
|
+
const module = this.#requireDashboardModule(input.moduleId);
|
|
441
|
+
const scopes = Object.entries(module.records ?? {})
|
|
442
|
+
.filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
|
|
443
|
+
.map(([collection]) => ({
|
|
444
|
+
moduleId: input.moduleId,
|
|
445
|
+
collection,
|
|
446
|
+
guildId: input.guildId,
|
|
447
|
+
}));
|
|
448
|
+
const writeBehind = scopes.length > 0 &&
|
|
449
|
+
this.#services.has(HELYX_SERVICE_NAMES.numericWriteBehind)
|
|
450
|
+
? this.#services.get(HELYX_SERVICE_NAMES.numericWriteBehind)
|
|
451
|
+
: null;
|
|
452
|
+
const setEnabled = () => this.#installations.setModuleEnabled({
|
|
346
453
|
guildId: input.guildId,
|
|
347
454
|
moduleId: input.moduleId,
|
|
348
455
|
enabled: input.enabled,
|
|
@@ -352,11 +459,56 @@ export class BotControlService {
|
|
|
352
459
|
source: "dashboard",
|
|
353
460
|
},
|
|
354
461
|
});
|
|
462
|
+
if (!writeBehind) {
|
|
463
|
+
await setEnabled();
|
|
464
|
+
}
|
|
465
|
+
else if (input.enabled) {
|
|
466
|
+
for (const scope of scopes) {
|
|
467
|
+
const suppression = await writeBehind.suppressGuild(scope);
|
|
468
|
+
await writeBehind.releaseGuild({
|
|
469
|
+
...scope,
|
|
470
|
+
suppressionId: suppression.suppressionId,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
await setEnabled();
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
const runBoundary = async (index) => {
|
|
477
|
+
const scope = scopes[index];
|
|
478
|
+
if (!scope) {
|
|
479
|
+
await setEnabled();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
await writeBehind.runGuildBarrier(scope, () => runBoundary(index + 1));
|
|
483
|
+
};
|
|
484
|
+
await runBoundary(0);
|
|
485
|
+
for (const scope of scopes)
|
|
486
|
+
await writeBehind.flushGuild(scope);
|
|
487
|
+
for (const scope of scopes)
|
|
488
|
+
await writeBehind.suppressGuild(scope);
|
|
489
|
+
}
|
|
355
490
|
return this.getGuildState(input.guildId);
|
|
356
491
|
}
|
|
357
492
|
async updateConfiguration(input) {
|
|
358
493
|
const module = this.#requireDashboardModule(input.moduleId);
|
|
359
494
|
await this.#validateDiscordSettings(input.guildId, module, input.value);
|
|
495
|
+
if (module.configuration?.validateUpdate) {
|
|
496
|
+
try {
|
|
497
|
+
const value = module.configuration.validate(input.value);
|
|
498
|
+
await module.configuration.validateUpdate({
|
|
499
|
+
guildId: input.guildId,
|
|
500
|
+
value,
|
|
501
|
+
services: this.#services,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (error instanceof ModuleStateValidationError)
|
|
506
|
+
throw error;
|
|
507
|
+
throw new DashboardActionValidationError(error instanceof Error
|
|
508
|
+
? error.message
|
|
509
|
+
: "The module rejected these settings.");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
360
512
|
const saved = await this.#configurations.update({
|
|
361
513
|
guildId: input.guildId,
|
|
362
514
|
moduleId: input.moduleId,
|
|
@@ -603,15 +755,13 @@ export class BotControlService {
|
|
|
603
755
|
}));
|
|
604
756
|
}
|
|
605
757
|
async executeManagedResourceAction(input) {
|
|
606
|
-
const module = this.#
|
|
758
|
+
const { module, resource } = await this.#prepareManagedResource(input);
|
|
607
759
|
const declared = module.manifest.dashboard?.resources
|
|
608
760
|
?.find(({ id }) => id === input.resourceType)
|
|
609
761
|
?.actions?.find(({ id }) => id === input.actionId);
|
|
610
|
-
const resource = this.#requireManagedResource(module, input.resourceType);
|
|
611
762
|
this.#requireManagedResourceOperation(module, input.resourceType, "action");
|
|
612
763
|
if (!declared)
|
|
613
764
|
throw new BotControlNotFoundError("Managed resource action is not declared by this module");
|
|
614
|
-
await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
|
|
615
765
|
const permissionId = managedResourcePermissionId(module, input.resourceType, "action", input.actionId, declared.permissionId);
|
|
616
766
|
const context = await this.#managedResourceContext(input, module, "action", permissionId);
|
|
617
767
|
validateManagedResourceActionValue(declared.inputs ?? [], input.value);
|
|
@@ -628,7 +778,8 @@ export class BotControlService {
|
|
|
628
778
|
async #prepareManagedResource(input) {
|
|
629
779
|
const module = this.#requireDashboardModule(input.moduleId);
|
|
630
780
|
const resource = this.#requireManagedResource(module, input.resourceType);
|
|
631
|
-
|
|
781
|
+
if (!resource.availableWhileDisabled)
|
|
782
|
+
await this.#requireEnabledManagedResource(input.guildId, input.moduleId);
|
|
632
783
|
return { module, resource };
|
|
633
784
|
}
|
|
634
785
|
#requireManagedResource(module, resourceType) {
|
|
@@ -667,6 +818,10 @@ export class BotControlService {
|
|
|
667
818
|
return {
|
|
668
819
|
guildId: input.guildId,
|
|
669
820
|
actor,
|
|
821
|
+
...("actorAuthenticatedAt" in input &&
|
|
822
|
+
typeof input.actorAuthenticatedAt === "string"
|
|
823
|
+
? { actorAuthenticatedAt: input.actorAuthenticatedAt }
|
|
824
|
+
: {}),
|
|
670
825
|
correlationId: input.correlationId,
|
|
671
826
|
services: this.#services,
|
|
672
827
|
sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
|
|
@@ -734,14 +889,25 @@ export class BotControlService {
|
|
|
734
889
|
if (setting.type === "discord-channel") {
|
|
735
890
|
if (configured === "" || configured === undefined)
|
|
736
891
|
continue;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
892
|
+
if (Boolean(setting.multiple) !== Array.isArray(configured))
|
|
893
|
+
throw new BotControlValidationError(`${setting.label} has an invalid Discord channel selection`);
|
|
894
|
+
const configuredChannels = Array.isArray(configured)
|
|
895
|
+
? configured
|
|
896
|
+
: [configured];
|
|
897
|
+
if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
|
|
898
|
+
new Set(configuredChannels).size !== configuredChannels.length ||
|
|
899
|
+
configuredChannels.some((channelId) => {
|
|
900
|
+
if (typeof channelId !== "string")
|
|
901
|
+
return true;
|
|
902
|
+
const channel = channels.get(channelId);
|
|
903
|
+
return !channel || !setting.channelTypes.includes(channel.type);
|
|
904
|
+
}))
|
|
905
|
+
throw new BotControlValidationError(`${setting.label} must contain available supported Discord channels`);
|
|
740
906
|
}
|
|
741
907
|
if (setting.type === "discord-role") {
|
|
742
908
|
if (configured === "" || configured === undefined)
|
|
743
909
|
continue;
|
|
744
|
-
if (setting.multiple !== Array.isArray(configured))
|
|
910
|
+
if (Boolean(setting.multiple) !== Array.isArray(configured))
|
|
745
911
|
throw new BotControlValidationError(`${setting.label} has an invalid Discord role selection`);
|
|
746
912
|
const configuredRoles = Array.isArray(configured)
|
|
747
913
|
? configured
|
|
@@ -810,6 +976,7 @@ export async function createBotControlServer(input) {
|
|
|
810
976
|
}
|
|
811
977
|
server.get("/health", (_request, reply) => readiness(reply));
|
|
812
978
|
server.get("/v1/health", (_request, reply) => readiness(reply));
|
|
979
|
+
server.post("/v1/verification/results", async (request) => input.service.acceptVerificationResult(signedAssessmentEnvelopeSchema.parse(request.body)));
|
|
813
980
|
server.get("/v1/shards/health", async (request, reply) => {
|
|
814
981
|
reply.header("Cache-Control", "no-store");
|
|
815
982
|
if (!input.shardHealth)
|
|
@@ -874,6 +1041,18 @@ export async function createBotControlServer(input) {
|
|
|
874
1041
|
await input.service.sendSupportNotification(body);
|
|
875
1042
|
return reply.code(204).send();
|
|
876
1043
|
});
|
|
1044
|
+
server.post("/v1/guilds/presence", async (request) => {
|
|
1045
|
+
const body = z
|
|
1046
|
+
.object({
|
|
1047
|
+
guildIds: z.array(snowflake).min(1).max(200),
|
|
1048
|
+
correlationId: z.uuid(),
|
|
1049
|
+
})
|
|
1050
|
+
.strict()
|
|
1051
|
+
.parse(request.body);
|
|
1052
|
+
return {
|
|
1053
|
+
items: await input.service.listGuildPresence(body),
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
877
1056
|
server.post("/v1/privacy/capture", async (request) => {
|
|
878
1057
|
const body = z
|
|
879
1058
|
.object({
|
|
@@ -882,11 +1061,13 @@ export async function createBotControlServer(input) {
|
|
|
882
1061
|
requestId: z.uuid(),
|
|
883
1062
|
subjectUserId: snowflake,
|
|
884
1063
|
cutoffAt: z.coerce.date(),
|
|
1064
|
+
operation: z.enum(["capture", "residual_scan"]).optional(),
|
|
885
1065
|
cursor: z.string().min(1).max(2_048).optional(),
|
|
886
1066
|
})
|
|
887
1067
|
.strict()
|
|
888
1068
|
.parse(request.body);
|
|
889
|
-
const { cursor, ...value } = body;
|
|
1069
|
+
const { cursor, operation: _operation, ...value } = body;
|
|
1070
|
+
void _operation;
|
|
890
1071
|
return input.service.capturePrivacy({
|
|
891
1072
|
...value,
|
|
892
1073
|
...(cursor ? { cursor } : {}),
|
|
@@ -914,6 +1095,28 @@ export async function createBotControlServer(input) {
|
|
|
914
1095
|
const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
|
|
915
1096
|
return input.service.getGuildState(guildId);
|
|
916
1097
|
});
|
|
1098
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/access", async (request) => {
|
|
1099
|
+
const params = z
|
|
1100
|
+
.object({
|
|
1101
|
+
guildId: snowflake,
|
|
1102
|
+
moduleId,
|
|
1103
|
+
resourceType: z.string().regex(/^[a-z][a-z0-9-]*$/u),
|
|
1104
|
+
})
|
|
1105
|
+
.parse(request.params);
|
|
1106
|
+
const body = z
|
|
1107
|
+
.object({
|
|
1108
|
+
operation: z.enum(["list", "read"]),
|
|
1109
|
+
actorUserId: snowflake,
|
|
1110
|
+
})
|
|
1111
|
+
.strict()
|
|
1112
|
+
.parse(request.body);
|
|
1113
|
+
return {
|
|
1114
|
+
allowed: await input.service.canAccessManagedResource({
|
|
1115
|
+
...params,
|
|
1116
|
+
...body,
|
|
1117
|
+
}),
|
|
1118
|
+
};
|
|
1119
|
+
});
|
|
917
1120
|
server.put("/v1/guilds/:guildId/modules/:moduleId/status", async (request) => {
|
|
918
1121
|
const params = z
|
|
919
1122
|
.object({ guildId: snowflake, moduleId })
|
|
@@ -1101,12 +1304,19 @@ export async function createBotControlServer(input) {
|
|
|
1101
1304
|
.extend({
|
|
1102
1305
|
expectedRevision: z.number().int().min(0),
|
|
1103
1306
|
value: managedResourceObject.optional().default({}),
|
|
1307
|
+
actorAuthenticatedAt: z.iso.datetime({ offset: true }).optional(),
|
|
1104
1308
|
})
|
|
1105
1309
|
.strict()
|
|
1106
1310
|
.parse(request.body);
|
|
1107
1311
|
return input.service.executeManagedResourceAction({
|
|
1108
1312
|
...params,
|
|
1109
|
-
|
|
1313
|
+
actorUserId: body.actorUserId,
|
|
1314
|
+
correlationId: body.correlationId,
|
|
1315
|
+
expectedRevision: body.expectedRevision,
|
|
1316
|
+
value: body.value,
|
|
1317
|
+
...(body.actorAuthenticatedAt === undefined
|
|
1318
|
+
? {}
|
|
1319
|
+
: { actorAuthenticatedAt: body.actorAuthenticatedAt }),
|
|
1110
1320
|
});
|
|
1111
1321
|
});
|
|
1112
1322
|
server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {
|