@helyx/bot 1.3.1 → 1.5.0
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 +100 -28
- package/README.md +65 -11
- package/dist/channel-audit-configuration.d.ts +7 -0
- package/dist/channel-audit-configuration.js +40 -0
- package/dist/channel-audit-control.d.ts +14 -0
- package/dist/channel-audit-control.js +61 -0
- package/dist/channel-audit-permissions.d.ts +56 -0
- package/dist/channel-audit-permissions.js +417 -0
- package/dist/channel-audit-routes.d.ts +4 -0
- package/dist/channel-audit-routes.js +42 -0
- package/dist/cloud-connector.d.ts +11 -4
- package/dist/cloud-connector.js +54 -15
- package/dist/cloud-control-dispatcher.d.ts +8 -3
- package/dist/cloud-control-dispatcher.js +84 -4
- package/dist/control-errors.d.ts +12 -0
- package/dist/control-errors.js +32 -0
- package/dist/control-server-schemas.d.ts +84 -0
- package/dist/control-server-schemas.js +101 -0
- package/dist/control-server.d.ts +25 -8
- package/dist/control-server.js +101 -152
- package/dist/core-retention.d.ts +26 -0
- package/dist/core-retention.js +49 -0
- package/dist/creator-guild-join-service.d.ts +25 -0
- package/dist/creator-guild-join-service.js +218 -0
- package/dist/creator-invite-service.d.ts +37 -0
- package/dist/creator-invite-service.js +686 -0
- package/dist/creator-onboarding-control.d.ts +12 -0
- package/dist/creator-onboarding-control.js +65 -0
- package/dist/creator-onboarding-platform.d.ts +20 -0
- package/dist/creator-onboarding-platform.js +58 -0
- package/dist/dashboard-action-control.d.ts +2 -2
- package/dist/dashboard-action-control.js +2 -3
- package/dist/database-bootstrap.d.ts +36 -0
- package/dist/database-bootstrap.js +101 -0
- package/dist/developer-operations-module.d.ts +1 -0
- package/dist/developer-operations-module.js +34 -16
- package/dist/discord-channel-audit.d.ts +9 -0
- package/dist/discord-channel-audit.js +195 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-owned-message-service.d.ts +1 -0
- package/dist/discord-owned-message-service.js +3 -0
- package/dist/discord-role-safety.d.ts +4 -0
- package/dist/discord-role-safety.js +30 -0
- package/dist/events.d.ts +2 -0
- package/dist/events.js +5 -20
- package/dist/guild-command-read-service.d.ts +8 -3
- package/dist/guild-command-read-service.js +14 -7
- package/dist/guild-command-release.d.ts +15 -8
- package/dist/guild-command-release.js +10 -3
- package/dist/guild-command-worker.d.ts +17 -4
- package/dist/guild-command-worker.js +17 -6
- package/dist/guilds.d.ts +24 -3
- package/dist/guilds.js +185 -64
- package/dist/hosted-server-moderation.js +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +200 -82
- package/dist/interactions.js +16 -3
- package/dist/module-configuration.d.ts +1 -1
- package/dist/module-configuration.js +5 -2
- package/dist/module-logging.d.ts +8 -3
- package/dist/module-logging.js +13 -7
- package/dist/modules.d.ts +1 -0
- package/dist/modules.js +8 -1
- package/dist/mysql-runtime-persistence.d.ts +46 -0
- package/dist/mysql-runtime-persistence.js +91 -0
- package/dist/oauth-module.js +2 -2
- package/dist/oauth-service.d.ts +7 -0
- package/dist/oauth-service.js +3 -0
- package/dist/scheduled-task-runner.d.ts +9 -85
- package/dist/scheduled-task-runner.js +1 -1
- package/dist/shard-contracts.d.ts +222 -0
- package/dist/shard-contracts.js +52 -1
- package/dist/shard-router.d.ts +4 -1
- package/dist/shard-router.js +19 -0
- package/dist/shard-supervisor.js +43 -9
- package/dist/shard-worker.d.ts +5 -2
- package/dist/shard-worker.js +41 -5
- package/dist/supervisor-cloud-directory.d.ts +9 -3
- package/dist/supervisor-cloud-directory.js +8 -12
- package/dist/trivia-data-provider.d.ts +11 -0
- package/dist/trivia-data-provider.js +46 -26
- package/dist/verification-service.d.ts +2 -2
- package/dist/verification-service.js +6 -3
- package/documentation/README.md +5 -3
- package/documentation/examples/.env.example +15 -11
- package/documentation/guides/getting-started.md +29 -12
- package/documentation/guides/privacy-and-support.md +6 -5
- package/documentation/guides/self-hosting.md +16 -11
- package/documentation/modules/auto-moderation.md +7 -5
- package/documentation/modules/building-a-module.md +48 -2
- package/documentation/modules/moderation.md +5 -5
- package/documentation/modules/publishing-a-module.md +6 -4
- package/documentation/modules/testing-a-module.md +39 -4
- package/documentation/reference/environment.md +43 -9
- package/documentation/runbooks/backup-and-recovery.md +26 -7
- package/documentation/runbooks/installation-and-upgrades.md +97 -27
- package/package.json +20 -16
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type CreatorOnboardingRequest, type CreatorOnboardingResponse, type InstallationService, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
export declare function registerCreatorOnboardingControlRoute(server: FastifyInstance, service: {
|
|
4
|
+
creatorOnboarding?(input: CreatorOnboardingRequest): Promise<CreatorOnboardingResponse>;
|
|
5
|
+
}): void;
|
|
6
|
+
/** Execute the public creator flow behind the bot's existing module guards. */
|
|
7
|
+
export declare function executeCreatorOnboarding(input: CreatorOnboardingRequest, dependencies: {
|
|
8
|
+
modules: ReadonlyMap<string, ModuleDefinition>;
|
|
9
|
+
installations: InstallationService;
|
|
10
|
+
services: ServiceAccess;
|
|
11
|
+
}): Promise<CreatorOnboardingResponse>;
|
|
12
|
+
//# sourceMappingURL=creator-onboarding-control.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { moduleAvailabilityService } from "./module-availability-control.js";
|
|
3
|
+
import { canAccessModule } from "./premium-access.js";
|
|
4
|
+
import { creatorOnboardingRequestSchema } from "@helyx/control-protocol";
|
|
5
|
+
import { BotControlTransportError } from "./control-errors.js";
|
|
6
|
+
export function registerCreatorOnboardingControlRoute(server, service) {
|
|
7
|
+
server.post("/v1/creator-onboarding", async (request) => {
|
|
8
|
+
if (!service.creatorOnboarding)
|
|
9
|
+
throw new BotControlTransportError(503, "Creator onboarding is unavailable");
|
|
10
|
+
return service.creatorOnboarding(creatorOnboardingRequestSchema.parse(request.body));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Execute the public creator flow behind the bot's existing module guards. */
|
|
14
|
+
export async function executeCreatorOnboarding(input, dependencies) {
|
|
15
|
+
const { modules, installations, services } = dependencies;
|
|
16
|
+
const module = modules.get("helyx.creator-connections");
|
|
17
|
+
if (!module?.creatorOnboarding ||
|
|
18
|
+
(moduleAvailabilityService(services) &&
|
|
19
|
+
!(await moduleAvailabilityService(services).isAvailable(module.manifest.id))) ||
|
|
20
|
+
!(await installations.isModuleEnabled(input.guildId, module.manifest.id)) ||
|
|
21
|
+
!(await canAccessModule(services, input.guildId, module)))
|
|
22
|
+
return { available: false };
|
|
23
|
+
if (input.action === "inspect") {
|
|
24
|
+
const page = await module.creatorOnboarding.execute({ action: "page", guildId: input.guildId, reference: input.reference }, services);
|
|
25
|
+
if (!page.available ||
|
|
26
|
+
page.page?.configurationRevision !== input.configurationRevision ||
|
|
27
|
+
!services.has(HELYX_SERVICE_NAMES.creatorGuildJoin))
|
|
28
|
+
return { available: false };
|
|
29
|
+
const status = await services
|
|
30
|
+
.get(HELYX_SERVICE_NAMES.creatorGuildJoin)
|
|
31
|
+
.inspect({ guildId: input.guildId, memberUserId: input.memberUserId });
|
|
32
|
+
return {
|
|
33
|
+
available: true,
|
|
34
|
+
memberReadiness: status.memberReadiness,
|
|
35
|
+
joinOutcome: ["member_missing", "unavailable"].includes(status.memberReadiness)
|
|
36
|
+
? "uncertain"
|
|
37
|
+
: "already_member",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (input.action !== "join")
|
|
41
|
+
return module.creatorOnboarding.execute(input, services);
|
|
42
|
+
const page = await module.creatorOnboarding.execute({ action: "page", guildId: input.guildId, reference: input.reference }, services);
|
|
43
|
+
if (!page.available ||
|
|
44
|
+
page.page?.configurationRevision !== input.configurationRevision ||
|
|
45
|
+
!services.has(HELYX_SERVICE_NAMES.creatorGuildJoin))
|
|
46
|
+
return { available: false };
|
|
47
|
+
const joined = await services
|
|
48
|
+
.get(HELYX_SERVICE_NAMES.creatorGuildJoin)
|
|
49
|
+
.join({
|
|
50
|
+
guildId: input.guildId,
|
|
51
|
+
memberUserId: input.memberUserId,
|
|
52
|
+
sessionId: input.sessionId,
|
|
53
|
+
authorisationReference: input.authorisationReference,
|
|
54
|
+
});
|
|
55
|
+
let effects = { available: true };
|
|
56
|
+
if (input.withTwitch && ["joined", "already_member"].includes(joined.outcome))
|
|
57
|
+
effects = await module.creatorOnboarding.execute({ ...input, action: "deliver" }, services);
|
|
58
|
+
return {
|
|
59
|
+
...effects,
|
|
60
|
+
available: true,
|
|
61
|
+
joinOutcome: joined.outcome,
|
|
62
|
+
memberReadiness: joined.memberReadiness,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=creator-onboarding-control.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DatabasePool } from "@helyx/database";
|
|
2
|
+
import { type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
|
|
3
|
+
import type { Client } from "discord.js";
|
|
4
|
+
/** Independent of customer-module start/stop so disabled invites can still be revoked. */
|
|
5
|
+
export declare function installCreatorInvitePlatform(input: {
|
|
6
|
+
client: Client;
|
|
7
|
+
database: DatabasePool;
|
|
8
|
+
services: ServiceAccess;
|
|
9
|
+
modules: readonly ModuleDefinition[];
|
|
10
|
+
encryptionKey: string | undefined;
|
|
11
|
+
oauthBroker?: {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
secret: string;
|
|
14
|
+
};
|
|
15
|
+
register(name: string, service: unknown): void;
|
|
16
|
+
warn(event: string): void;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
} | undefined>;
|
|
20
|
+
//# sourceMappingURL=creator-onboarding-platform.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CreatorInviteRepository } from "@helyx/database";
|
|
2
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
|
+
import { DiscordCreatorInviteService } from "./creator-invite-service.js";
|
|
4
|
+
import { DiscordCreatorGuildJoinService } from "./creator-guild-join-service.js";
|
|
5
|
+
/** Independent of customer-module start/stop so disabled invites can still be revoked. */
|
|
6
|
+
export async function installCreatorInvitePlatform(input) {
|
|
7
|
+
const module = input.modules.find((candidate) => candidate.manifest.id === "helyx.creator-connections");
|
|
8
|
+
const repository = new CreatorInviteRepository(input.database);
|
|
9
|
+
if (!module && !(await repository.isInstalled()))
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!input.encryptionKey)
|
|
12
|
+
throw new Error("Hosted Creator Connections invites require HELYX_DATA_ENCRYPTION_KEY");
|
|
13
|
+
const service = new DiscordCreatorInviteService(input.client, repository, input.services, module, input.encryptionKey);
|
|
14
|
+
input.register(HELYX_SERVICE_NAMES.creatorInvites, service);
|
|
15
|
+
if (input.oauthBroker) {
|
|
16
|
+
const broker = input.oauthBroker;
|
|
17
|
+
input.register(HELYX_SERVICE_NAMES.creatorGuildJoin, new DiscordCreatorGuildJoinService(input.client, input.services, input.modules, repository, async (request) => {
|
|
18
|
+
const response = await fetch(new URL("/v1/internal/oauth/onboarding/credential", broker.baseUrl), {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
authorization: `Bearer ${broker.secret}`,
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify(request),
|
|
25
|
+
signal: AbortSignal.timeout(10_000),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error("creator_join_authorisation_unavailable");
|
|
29
|
+
const result = (await response.json());
|
|
30
|
+
if (typeof result.accessToken !== "string" ||
|
|
31
|
+
typeof result.applicationId !== "string")
|
|
32
|
+
throw new Error("creator_join_authorisation_invalid");
|
|
33
|
+
return {
|
|
34
|
+
accessToken: result.accessToken,
|
|
35
|
+
applicationId: result.applicationId,
|
|
36
|
+
};
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
let pending;
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
if (pending || !input.client.isReady())
|
|
42
|
+
return;
|
|
43
|
+
pending = service
|
|
44
|
+
.cleanupUnavailableInvites()
|
|
45
|
+
.catch(() => input.warn("creator_invites.cleanup_delayed"))
|
|
46
|
+
.finally(() => {
|
|
47
|
+
pending = undefined;
|
|
48
|
+
});
|
|
49
|
+
}, 30_000);
|
|
50
|
+
timer.unref();
|
|
51
|
+
return {
|
|
52
|
+
async stop() {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
await pending;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=creator-onboarding-platform.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { InstallationRepository } from "@helyx/database";
|
|
2
2
|
import { type DashboardActionContext, type DashboardActionInspection, type DashboardActionResult, type ModuleDefinition, type PermissionActor, type ServiceAccess } from "@helyx/sdk";
|
|
3
3
|
export interface DashboardActionInput {
|
|
4
4
|
guildId: string;
|
|
@@ -10,7 +10,7 @@ export interface DashboardActionInput {
|
|
|
10
10
|
}
|
|
11
11
|
export type DashboardActionPreviewInput = Omit<DashboardActionInput, "inspectionRevision">;
|
|
12
12
|
export interface DashboardActionControl {
|
|
13
|
-
|
|
13
|
+
installations: Pick<InstallationRepository, "isModuleEnabled">;
|
|
14
14
|
services: ServiceAccess;
|
|
15
15
|
requireDashboardModule(moduleId: string): ModuleDefinition;
|
|
16
16
|
requireModuleAccessible(guildId: string, moduleId: string): Promise<void>;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { InstallationRepository } from "@helyx/database";
|
|
2
1
|
import {} from "@helyx/sdk";
|
|
3
2
|
import { dashboardActionPermissionId } from "./control-permissions.js";
|
|
4
3
|
import { requireDashboardFeatures } from "./dashboard-feature-control.js";
|
|
@@ -10,7 +9,7 @@ function prepareAction(input, control) {
|
|
|
10
9
|
}
|
|
11
10
|
async function requireEnabledAndAuthorized(input, control, module, permissionId, disabledMessage) {
|
|
12
11
|
const [enabled] = await Promise.all([
|
|
13
|
-
|
|
12
|
+
control.installations.isModuleEnabled(input.guildId, input.moduleId),
|
|
14
13
|
control.authorizeAction(input.guildId, module, input.actorUserId, permissionId),
|
|
15
14
|
]);
|
|
16
15
|
if (!enabled)
|
|
@@ -25,7 +24,7 @@ function actionContext(input, control, module, permissionId) {
|
|
|
25
24
|
revalidate: async () => {
|
|
26
25
|
try {
|
|
27
26
|
await control.requireModuleAccessible(input.guildId, input.moduleId);
|
|
28
|
-
const enabled = await
|
|
27
|
+
const enabled = await control.installations.isModuleEnabled(input.guildId, input.moduleId);
|
|
29
28
|
if (!enabled)
|
|
30
29
|
return false;
|
|
31
30
|
await control.authorizeAction(input.guildId, module, input.actorUserId, permissionId);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BotEnvironment } from "@helyx/config";
|
|
2
|
+
import { resolveDatabaseAdapter } from "@helyx/database/adapters";
|
|
3
|
+
import type { DatabaseAdapter, DatabaseSession } from "@helyx/database-contracts";
|
|
4
|
+
import { type ConfiguredModulePackage } from "./modules.js";
|
|
5
|
+
type MigrationResult = Awaited<ReturnType<DatabaseSession<unknown>["migrations"]["runCore"]>>;
|
|
6
|
+
type MigrationInspection = Awaited<ReturnType<DatabaseSession<unknown>["migrations"]["inspectCore"]>>;
|
|
7
|
+
export interface BotDatabaseStartupResult {
|
|
8
|
+
readonly core: MigrationResult;
|
|
9
|
+
readonly modules: readonly {
|
|
10
|
+
moduleId: string;
|
|
11
|
+
applied: readonly string[];
|
|
12
|
+
verified: readonly string[];
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
export interface BotDatabaseHandle<Repositories = Record<string, never>> {
|
|
16
|
+
readonly session: DatabaseSession<Repositories>;
|
|
17
|
+
readonly migrations: BotDatabaseStartupResult;
|
|
18
|
+
/** A read-only adapter query that also verifies the core migration ledger. */
|
|
19
|
+
healthCheck(): Promise<MigrationInspection>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface BotDatabaseBootstrapInput {
|
|
23
|
+
readonly environment: Pick<BotEnvironment, "DATABASE_ENGINE" | "DATABASE_URL" | "DATABASE_SSL" | "HELYX_RUNTIME_PROFILE">;
|
|
24
|
+
readonly modulePackages: readonly ConfiguredModulePackage[];
|
|
25
|
+
readonly applicationName?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Engine-neutral database startup foundation. Bot repositories and platform
|
|
29
|
+
* services still need an engine-aware runtime before this can replace the
|
|
30
|
+
* PostgreSQL-only startup path.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openBotDatabase(input: BotDatabaseBootstrapInput, resolveAdapter?: typeof resolveDatabaseAdapter): Promise<BotDatabaseHandle>;
|
|
33
|
+
/** Connect with an explicit repository-bearing adapter and the same startup checks. */
|
|
34
|
+
export declare function openBotDatabaseWithAdapter<Repositories>(input: BotDatabaseBootstrapInput, adapter: DatabaseAdapter<Repositories>): Promise<BotDatabaseHandle<Repositories>>;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=database-bootstrap.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolveDatabaseAdapter } from "@helyx/database/adapters";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertConfiguredModuleDatabaseCompatibility, } from "./modules.js";
|
|
5
|
+
/**
|
|
6
|
+
* Engine-neutral database startup foundation. Bot repositories and platform
|
|
7
|
+
* services still need an engine-aware runtime before this can replace the
|
|
8
|
+
* PostgreSQL-only startup path.
|
|
9
|
+
*/
|
|
10
|
+
export async function openBotDatabase(input, resolveAdapter = resolveDatabaseAdapter) {
|
|
11
|
+
const prepared = await prepareBotDatabase(input);
|
|
12
|
+
const adapter = await resolveAdapter(prepared.engine);
|
|
13
|
+
return connectPreparedBotDatabase(input, prepared, adapter);
|
|
14
|
+
}
|
|
15
|
+
/** Connect with an explicit repository-bearing adapter and the same startup checks. */
|
|
16
|
+
export async function openBotDatabaseWithAdapter(input, adapter) {
|
|
17
|
+
const prepared = await prepareBotDatabase(input);
|
|
18
|
+
return connectPreparedBotDatabase(input, prepared, adapter);
|
|
19
|
+
}
|
|
20
|
+
async function prepareBotDatabase(input) {
|
|
21
|
+
const { environment, modulePackages } = input;
|
|
22
|
+
const engine = environment.DATABASE_ENGINE;
|
|
23
|
+
if (engine !== "postgresql" && engine !== "mysql") {
|
|
24
|
+
throw new Error(`Unsupported bot DATABASE_ENGINE=${String(engine)}`);
|
|
25
|
+
}
|
|
26
|
+
if (engine === "mysql" &&
|
|
27
|
+
environment.HELYX_RUNTIME_PROFILE !== "self-hosted") {
|
|
28
|
+
throw new Error("Bot MySQL database startup requires HELYX_RUNTIME_PROFILE=self-hosted");
|
|
29
|
+
}
|
|
30
|
+
assertConfiguredModuleDatabaseCompatibility(modulePackages, engine);
|
|
31
|
+
const migrationSources = await Promise.all(modulePackages.map(async ({ definition, packageRoot }) => {
|
|
32
|
+
const manifest = definition.manifest;
|
|
33
|
+
const migrationsDirectory = join(packageRoot, engine === "mysql" ? "migrations-mysql" : "migrations");
|
|
34
|
+
if (engine === "mysql") {
|
|
35
|
+
const directory = await stat(migrationsDirectory).catch((error) => {
|
|
36
|
+
if (error.code === "ENOENT")
|
|
37
|
+
return undefined;
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
const postgresDirectory = await stat(join(packageRoot, "migrations")).catch((error) => {
|
|
41
|
+
if (error.code === "ENOENT")
|
|
42
|
+
return undefined;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
if ((!directory || !directory.isDirectory()) &&
|
|
46
|
+
postgresDirectory?.isDirectory()) {
|
|
47
|
+
throw new Error(`Module ${manifest.id} is missing its MySQL migration directory: ${migrationsDirectory}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
moduleId: manifest.id,
|
|
52
|
+
moduleVersion: manifest.version,
|
|
53
|
+
migrationsDirectory,
|
|
54
|
+
};
|
|
55
|
+
}));
|
|
56
|
+
return { engine, migrationSources };
|
|
57
|
+
}
|
|
58
|
+
async function connectPreparedBotDatabase(input, prepared, adapter) {
|
|
59
|
+
const { environment } = input;
|
|
60
|
+
const { engine, migrationSources } = prepared;
|
|
61
|
+
if (adapter.engine !== engine) {
|
|
62
|
+
throw new Error(`Database adapter mismatch: requested ${engine}, received ${adapter.engine}`);
|
|
63
|
+
}
|
|
64
|
+
const session = await adapter.connect({
|
|
65
|
+
connectionString: environment.DATABASE_URL,
|
|
66
|
+
sslMode: environment.DATABASE_SSL,
|
|
67
|
+
applicationName: input.applicationName ?? "helyx-bot",
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
if (session.identity.engine !== engine) {
|
|
71
|
+
throw new Error(`Database identity mismatch: requested ${engine}, received ${session.identity.engine}`);
|
|
72
|
+
}
|
|
73
|
+
const core = await session.migrations.runCore();
|
|
74
|
+
const modules = [];
|
|
75
|
+
for (const source of migrationSources) {
|
|
76
|
+
const result = await session.migrations.runModule(source);
|
|
77
|
+
modules.push({ moduleId: source.moduleId, ...result });
|
|
78
|
+
}
|
|
79
|
+
let closed = false;
|
|
80
|
+
return {
|
|
81
|
+
session,
|
|
82
|
+
migrations: { core, modules },
|
|
83
|
+
healthCheck() {
|
|
84
|
+
if (closed)
|
|
85
|
+
return Promise.reject(new Error("Bot database is closed"));
|
|
86
|
+
return session.migrations.inspectCore();
|
|
87
|
+
},
|
|
88
|
+
async close() {
|
|
89
|
+
if (closed)
|
|
90
|
+
return;
|
|
91
|
+
closed = true;
|
|
92
|
+
await session.close();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await session.close().catch(() => undefined);
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=database-bootstrap.js.map
|
|
@@ -15,6 +15,7 @@ export interface DeveloperOperationsInput {
|
|
|
15
15
|
approvedRoleIds: readonly string[];
|
|
16
16
|
botVersion: string;
|
|
17
17
|
deploymentReference?: string;
|
|
18
|
+
databaseEngine: "postgresql" | "mysql";
|
|
18
19
|
coreFunctions: readonly DeveloperModuleSummary[];
|
|
19
20
|
modules: readonly DeveloperModuleSummary[];
|
|
20
21
|
processLabel: string;
|
|
@@ -5,7 +5,6 @@ import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
|
5
5
|
const DEVELOPER_MODULE_ID = "helyx.developer-operations";
|
|
6
6
|
const DEVELOPER_MODULE_NAME = "Helyx Developer Operations";
|
|
7
7
|
const DEVELOPER_MODULE_VERSION = "0.3.0";
|
|
8
|
-
const MAX_MODULE_LINES = 8;
|
|
9
8
|
export function createDeveloperOperationsModule(input) {
|
|
10
9
|
const approvedRoles = new Set(input.approvedRoleIds);
|
|
11
10
|
return defineModule({
|
|
@@ -27,6 +26,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
27
26
|
commands: Object.fromEntries([
|
|
28
27
|
"dev.shards",
|
|
29
28
|
"dev.bot",
|
|
29
|
+
"dev.modules",
|
|
30
30
|
"dev.proc",
|
|
31
31
|
"dev.trivia-data",
|
|
32
32
|
"dev.leave",
|
|
@@ -96,7 +96,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
96
96
|
{
|
|
97
97
|
name: "bot",
|
|
98
98
|
permissionId: "dev.bot",
|
|
99
|
-
description: "Show the bot build and
|
|
99
|
+
description: "Show the bot build and core function versions",
|
|
100
100
|
acknowledgement: "immediate-response",
|
|
101
101
|
authorization: "self-authorized",
|
|
102
102
|
async execute(context) {
|
|
@@ -110,13 +110,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
110
110
|
},
|
|
111
111
|
...input.coreFunctions,
|
|
112
112
|
].sort((left, right) => left.id.localeCompare(right.id));
|
|
113
|
-
const
|
|
114
|
-
const visibleCoreFunctions = coreFunctions.slice(0, MAX_MODULE_LINES);
|
|
115
|
-
const visibleModules = modules.slice(0, Math.max(0, MAX_MODULE_LINES - visibleCoreFunctions.length));
|
|
116
|
-
const coreFunctionLines = visibleCoreFunctions.map((coreFunction) => `- ${safeInlineCode(coreFunction.name)} · ${safeInlineCode(coreFunction.id)} · ${safeInlineCode(coreFunction.version)}`);
|
|
117
|
-
const moduleLines = visibleModules.map((module) => `- ${safeInlineCode(module.name)} · ${safeInlineCode(module.id)} · ${safeInlineCode(module.version)}`);
|
|
118
|
-
const componentCount = coreFunctions.length + modules.length;
|
|
119
|
-
const visibleComponentCount = visibleCoreFunctions.length + visibleModules.length;
|
|
113
|
+
const coreFunctionLines = coreFunctions.map((coreFunction) => `- ${safeInlineCode(coreFunction.name)} · ${safeInlineCode(coreFunction.version)}`);
|
|
120
114
|
await context.reply({
|
|
121
115
|
ephemeral: true,
|
|
122
116
|
componentsV2: {
|
|
@@ -127,19 +121,43 @@ export function createDeveloperOperationsModule(input) {
|
|
|
127
121
|
"## Bot information",
|
|
128
122
|
`Version: ${safeInlineCode(input.botVersion)}`,
|
|
129
123
|
`Build: ${safeInlineCode(input.deploymentReference ?? "not supplied")}`,
|
|
124
|
+
`Database: ${safeInlineCode(input.databaseEngine === "mysql" ? "MySQL" : "PostgreSQL")}`,
|
|
130
125
|
`Runtime: ${input.processLabel}`,
|
|
131
126
|
`Core functions: ${coreFunctions.length}`,
|
|
132
|
-
`Installed modules: ${modules.length}`,
|
|
127
|
+
`Installed modules: ${input.modules.length} — see /dev modules`,
|
|
133
128
|
"",
|
|
134
129
|
"### Core functions",
|
|
135
130
|
...coreFunctionLines,
|
|
131
|
+
].join("\n"),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "modules",
|
|
140
|
+
permissionId: "dev.modules",
|
|
141
|
+
description: "List all installed feature modules and their versions",
|
|
142
|
+
acknowledgement: "immediate-response",
|
|
143
|
+
authorization: "self-authorized",
|
|
144
|
+
async execute(context) {
|
|
145
|
+
if (!(await authorizeDeveloperCommand(context)))
|
|
146
|
+
return;
|
|
147
|
+
const modules = [...input.modules].sort((left, right) => left.id.localeCompare(right.id));
|
|
148
|
+
await context.reply({
|
|
149
|
+
ephemeral: true,
|
|
150
|
+
componentsV2: {
|
|
151
|
+
accentColor: 0x2563eb,
|
|
152
|
+
text: [
|
|
153
|
+
{
|
|
154
|
+
content: [
|
|
155
|
+
"## Installed modules",
|
|
156
|
+
`Total: ${modules.length}`,
|
|
136
157
|
"",
|
|
137
|
-
|
|
138
|
-
...
|
|
139
|
-
|
|
140
|
-
? [
|
|
141
|
-
`… showing ${visibleComponentCount} of ${componentCount} components`,
|
|
142
|
-
]
|
|
158
|
+
...modules.map((module) => `- ${safeInlineCode(module.name)} · ${safeInlineCode(module.version)}`),
|
|
159
|
+
...(modules.length === 0
|
|
160
|
+
? ["No feature modules installed."]
|
|
143
161
|
: []),
|
|
144
162
|
].join("\n"),
|
|
145
163
|
},
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Client } from "discord.js";
|
|
2
|
+
import type { ChannelAuditInput, ChannelAuditModuleId, ChannelAuditReport } from "@helyx/sdk";
|
|
3
|
+
/** Fresh guild roles/channels and the bot member only. Never reads messages or enumerates members. */
|
|
4
|
+
export declare function inspectDiscordChannelAudit(client: Client, input: {
|
|
5
|
+
guildId: string;
|
|
6
|
+
moduleId: ChannelAuditModuleId;
|
|
7
|
+
assumptions: ChannelAuditInput;
|
|
8
|
+
}): Promise<ChannelAuditReport>;
|
|
9
|
+
//# sourceMappingURL=discord-channel-audit.d.ts.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { PermissionFlagsBits } from "discord.js";
|
|
2
|
+
import { channelAuditInputSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
|
|
3
|
+
import { buildChannelAuditReport, } from "./channel-audit-permissions.js";
|
|
4
|
+
import { dangerousRolePermissions } from "./discord-role-safety.js";
|
|
5
|
+
async function readBefore(read, deadline) {
|
|
6
|
+
const remaining = deadline - Date.now();
|
|
7
|
+
if (remaining <= 0)
|
|
8
|
+
throw new Error("Channel audit read deadline reached");
|
|
9
|
+
let timer;
|
|
10
|
+
try {
|
|
11
|
+
return await Promise.race([
|
|
12
|
+
read(),
|
|
13
|
+
new Promise((_, reject) => {
|
|
14
|
+
timer = setTimeout(() => reject(new Error("Channel audit read deadline reached")), remaining);
|
|
15
|
+
timer.unref();
|
|
16
|
+
}),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
if (timer)
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Fresh guild roles/channels and the bot member only. Never reads messages or enumerates members. */
|
|
25
|
+
export async function inspectDiscordChannelAudit(client, input) {
|
|
26
|
+
const assumptions = channelAuditInputSchema.parse(input.assumptions);
|
|
27
|
+
const deadline = Date.now() + 8_500;
|
|
28
|
+
const snapshot = {
|
|
29
|
+
guildId: input.guildId,
|
|
30
|
+
roles: [],
|
|
31
|
+
rolesComplete: false,
|
|
32
|
+
channels: [],
|
|
33
|
+
fetchedChannels: 0,
|
|
34
|
+
totalChannelsKnown: false,
|
|
35
|
+
fetchLimitations: [],
|
|
36
|
+
};
|
|
37
|
+
const guild = await readBefore(() => client.guilds.fetch(input.guildId), deadline).catch(() => null);
|
|
38
|
+
if (!guild) {
|
|
39
|
+
snapshot.fetchLimitations.push("The current Discord server could not be fetched; channel and role coverage is incomplete.");
|
|
40
|
+
return channelAuditReportSchema.parse(buildChannelAuditReport(snapshot, input.moduleId, assumptions, new Date().toISOString()));
|
|
41
|
+
}
|
|
42
|
+
if (guild.id !== input.guildId)
|
|
43
|
+
throw new Error("Discord returned a different server for this inspection");
|
|
44
|
+
const [rolesResult, channelsResult, memberResult] = await Promise.allSettled([
|
|
45
|
+
readBefore(() => guild.roles.fetch(), deadline),
|
|
46
|
+
readBefore(() => guild.channels.fetch(), deadline),
|
|
47
|
+
readBefore(() => guild.members.fetchMe({ force: true }), deadline),
|
|
48
|
+
]);
|
|
49
|
+
const me = memberResult.status === "fulfilled" ? memberResult.value : null;
|
|
50
|
+
if (me && me.guild.id !== guild.id)
|
|
51
|
+
throw new Error("Discord bot member belongs to a different server");
|
|
52
|
+
if (!me)
|
|
53
|
+
snapshot.fetchLimitations.push("Helyx's current server member could not be fetched; bot publishing and assignment checks may be unknown.");
|
|
54
|
+
if (rolesResult.status === "fulfilled") {
|
|
55
|
+
snapshot.rolesComplete = true;
|
|
56
|
+
const canManage = me?.permissions.has(PermissionFlagsBits.ManageRoles) ?? false;
|
|
57
|
+
snapshot.roles = [...rolesResult.value.values()].map((role) => {
|
|
58
|
+
if (role.guild.id !== guild.id)
|
|
59
|
+
throw new Error("Discord role belongs to a different server");
|
|
60
|
+
const reason = role.id === guild.id
|
|
61
|
+
? "The everyone role cannot be assigned."
|
|
62
|
+
: role.managed
|
|
63
|
+
? "This role is managed by Discord or an integration and cannot be assigned by Helyx."
|
|
64
|
+
: !me
|
|
65
|
+
? undefined
|
|
66
|
+
: !canManage
|
|
67
|
+
? "Helyx lacks Manage Roles in this server."
|
|
68
|
+
: role.comparePositionTo(me.roles.highest) >= 0
|
|
69
|
+
? "The selected role is at or above Helyx's highest role."
|
|
70
|
+
: undefined;
|
|
71
|
+
const dangerous = dangerousRolePermissions(role.permissions.bitfield);
|
|
72
|
+
return {
|
|
73
|
+
id: role.id,
|
|
74
|
+
permissions: role.permissions.bitfield,
|
|
75
|
+
managed: role.managed,
|
|
76
|
+
assignable: reason ? false : me ? true : null,
|
|
77
|
+
...(reason ? { assignmentReason: reason } : {}),
|
|
78
|
+
...(dangerous.length
|
|
79
|
+
? {
|
|
80
|
+
assignmentPolicyReason: `Helyx's role-safety policy blocks managing roles with ${dangerous.join(", ")}. Discord hierarchy alone does not make this role eligible.`,
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
snapshot.fetchLimitations.push("The current Discord role list could not be fetched; missing roles are unknown, not confirmed deleted.");
|
|
88
|
+
const observedIds = new Set();
|
|
89
|
+
if (channelsResult.status === "fulfilled") {
|
|
90
|
+
snapshot.fetchedChannels = channelsResult.value.size;
|
|
91
|
+
for (const channel of channelsResult.value.values()) {
|
|
92
|
+
if (!channel)
|
|
93
|
+
continue;
|
|
94
|
+
if (channel.guild.id !== guild.id)
|
|
95
|
+
throw new Error("Discord channel belongs to a different server");
|
|
96
|
+
observedIds.add(channel.id);
|
|
97
|
+
const flags = "flags" in channel ? BigInt(channel.flags.bitfield) : 0n;
|
|
98
|
+
const obfuscated = (flags & 131072n) !== 0n || channel.name === "___hidden___";
|
|
99
|
+
const overwrites = "permissionOverwrites" in channel
|
|
100
|
+
? [...channel.permissionOverwrites.cache.values()].map((overwrite) => ({
|
|
101
|
+
id: overwrite.id,
|
|
102
|
+
type: overwrite.type,
|
|
103
|
+
allow: overwrite.allow.bitfield,
|
|
104
|
+
deny: overwrite.deny.bitfield,
|
|
105
|
+
}))
|
|
106
|
+
: [];
|
|
107
|
+
const botPermissions = !obfuscated && me
|
|
108
|
+
? (channel.permissionsFor(me)?.bitfield ?? null)
|
|
109
|
+
: null;
|
|
110
|
+
snapshot.channels.push({
|
|
111
|
+
id: channel.id,
|
|
112
|
+
name: obfuscated ? "Unknown channel" : channel.name,
|
|
113
|
+
type: channel.type,
|
|
114
|
+
obfuscated,
|
|
115
|
+
overwrites: obfuscated ? [] : overwrites,
|
|
116
|
+
botPermissions,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
snapshot.totalChannelsKnown =
|
|
120
|
+
me?.permissions.has(PermissionFlagsBits.Administrator) ?? false;
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
snapshot.fetchLimitations.push("Discord's channel list could not be fetched; cached metadata is not used as fresh permission evidence.");
|
|
124
|
+
// Bulk lists can omit invisible channels. Resolve only explicitly selected
|
|
125
|
+
// missing IDs; a permission failure or deadline remains unknown.
|
|
126
|
+
const selected = new Set([
|
|
127
|
+
assumptions.entryChannelId,
|
|
128
|
+
...assumptions.allowedChannelIds,
|
|
129
|
+
...(assumptions.reviewChannelId ? [assumptions.reviewChannelId] : []),
|
|
130
|
+
]);
|
|
131
|
+
snapshot.invalidSelectedChannels = [];
|
|
132
|
+
for (const id of selected) {
|
|
133
|
+
if (observedIds.has(id))
|
|
134
|
+
continue;
|
|
135
|
+
try {
|
|
136
|
+
const channel = await readBefore(() => guild.channels.fetch(id, { force: true, cache: false }), deadline);
|
|
137
|
+
if (!channel)
|
|
138
|
+
continue;
|
|
139
|
+
if (channel.guild.id !== guild.id) {
|
|
140
|
+
snapshot.invalidSelectedChannels.push({ id, reason: "wrong_guild" });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
observedIds.add(id);
|
|
144
|
+
const obfuscated = (BigInt("flags" in channel ? channel.flags.bitfield : 0) & 131072n) !==
|
|
145
|
+
0n || channel.name === "___hidden___";
|
|
146
|
+
snapshot.channels.push({
|
|
147
|
+
id,
|
|
148
|
+
name: obfuscated ? "Unknown channel" : channel.name,
|
|
149
|
+
type: channel.type,
|
|
150
|
+
obfuscated,
|
|
151
|
+
overwrites: !obfuscated && "permissionOverwrites" in channel
|
|
152
|
+
? [...channel.permissionOverwrites.cache.values()].map((overwrite) => ({
|
|
153
|
+
id: overwrite.id,
|
|
154
|
+
type: overwrite.type,
|
|
155
|
+
allow: overwrite.allow.bitfield,
|
|
156
|
+
deny: overwrite.deny.bitfield,
|
|
157
|
+
}))
|
|
158
|
+
: [],
|
|
159
|
+
botPermissions: !obfuscated && me
|
|
160
|
+
? (channel.permissionsFor(me)?.bitfield ?? null)
|
|
161
|
+
: null,
|
|
162
|
+
});
|
|
163
|
+
snapshot.fetchedChannels += 1;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
const code = error && typeof error === "object" && "code" in error
|
|
167
|
+
? error.code
|
|
168
|
+
: undefined;
|
|
169
|
+
if (code === 10003 || code === "GuildChannelUnowned")
|
|
170
|
+
snapshot.invalidSelectedChannels.push({
|
|
171
|
+
id,
|
|
172
|
+
reason: code === 10003 ? "not_found" : "wrong_guild",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Cache-only entries establish known inventory, never current permissions or deletion.
|
|
177
|
+
for (const channel of guild.channels.cache.values()) {
|
|
178
|
+
if (observedIds.has(channel.id))
|
|
179
|
+
continue;
|
|
180
|
+
snapshot.channels.push(unknownChannel(channel));
|
|
181
|
+
snapshot.totalChannelsKnown = false;
|
|
182
|
+
}
|
|
183
|
+
return channelAuditReportSchema.parse(buildChannelAuditReport(snapshot, input.moduleId, assumptions, new Date().toISOString()));
|
|
184
|
+
}
|
|
185
|
+
function unknownChannel(channel) {
|
|
186
|
+
return {
|
|
187
|
+
id: channel.id,
|
|
188
|
+
name: "Unknown channel",
|
|
189
|
+
type: channel.type,
|
|
190
|
+
obfuscated: true,
|
|
191
|
+
overwrites: [],
|
|
192
|
+
botPermissions: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=discord-channel-audit.js.map
|
|
@@ -163,8 +163,8 @@ export declare class DiscordGuildHealthInspection implements DiscordGuildHealthI
|
|
|
163
163
|
resourceValidationValid: boolean | null;
|
|
164
164
|
activationValid: boolean | null;
|
|
165
165
|
catalogueMatches: boolean;
|
|
166
|
-
requiredCapabilities: readonly ("
|
|
167
|
-
missingCapabilities: ("
|
|
166
|
+
requiredCapabilities: readonly ("message-content" | "guild-members")[];
|
|
167
|
+
missingCapabilities: ("message-content" | "guild-members")[];
|
|
168
168
|
missingPermissions: string[];
|
|
169
169
|
healthStatus: "healthy" | "degraded" | "unhealthy";
|
|
170
170
|
healthMessage: string | null;
|