@helyx/bot 1.3.1 → 1.4.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +77 -28
  2. package/README.md +65 -11
  3. package/dist/cloud-connector.d.ts +11 -4
  4. package/dist/cloud-connector.js +52 -15
  5. package/dist/cloud-control-dispatcher.d.ts +8 -3
  6. package/dist/cloud-control-dispatcher.js +62 -4
  7. package/dist/control-errors.d.ts +4 -0
  8. package/dist/control-errors.js +8 -0
  9. package/dist/control-server-schemas.d.ts +84 -0
  10. package/dist/control-server-schemas.js +101 -0
  11. package/dist/control-server.d.ts +21 -7
  12. package/dist/control-server.js +88 -132
  13. package/dist/core-retention.d.ts +26 -0
  14. package/dist/core-retention.js +49 -0
  15. package/dist/creator-guild-join-service.d.ts +25 -0
  16. package/dist/creator-guild-join-service.js +218 -0
  17. package/dist/creator-invite-service.d.ts +37 -0
  18. package/dist/creator-invite-service.js +686 -0
  19. package/dist/creator-onboarding-control.d.ts +12 -0
  20. package/dist/creator-onboarding-control.js +65 -0
  21. package/dist/creator-onboarding-platform.d.ts +20 -0
  22. package/dist/creator-onboarding-platform.js +58 -0
  23. package/dist/dashboard-action-control.d.ts +2 -2
  24. package/dist/dashboard-action-control.js +2 -3
  25. package/dist/database-bootstrap.d.ts +36 -0
  26. package/dist/database-bootstrap.js +101 -0
  27. package/dist/developer-operations-module.d.ts +1 -0
  28. package/dist/developer-operations-module.js +34 -16
  29. package/dist/discord-owned-message-service.d.ts +1 -0
  30. package/dist/discord-owned-message-service.js +3 -0
  31. package/dist/guild-command-read-service.d.ts +8 -3
  32. package/dist/guild-command-read-service.js +14 -7
  33. package/dist/guild-command-release.d.ts +15 -8
  34. package/dist/guild-command-release.js +10 -3
  35. package/dist/guild-command-worker.d.ts +17 -4
  36. package/dist/guild-command-worker.js +17 -6
  37. package/dist/guilds.d.ts +24 -3
  38. package/dist/guilds.js +185 -64
  39. package/dist/hosted-server-moderation.d.ts +2 -2
  40. package/dist/hosted-server-moderation.js +10 -4
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +200 -82
  43. package/dist/interactions.js +16 -3
  44. package/dist/module-configuration.d.ts +1 -1
  45. package/dist/module-configuration.js +5 -2
  46. package/dist/module-logging.d.ts +8 -3
  47. package/dist/module-logging.js +13 -7
  48. package/dist/modules.d.ts +1 -0
  49. package/dist/modules.js +8 -1
  50. package/dist/mysql-runtime-persistence.d.ts +46 -0
  51. package/dist/mysql-runtime-persistence.js +91 -0
  52. package/dist/oauth-module.js +2 -2
  53. package/dist/oauth-service.d.ts +7 -0
  54. package/dist/oauth-service.js +3 -0
  55. package/dist/scheduled-task-runner.d.ts +9 -85
  56. package/dist/scheduled-task-runner.js +1 -1
  57. package/dist/shard-contracts.d.ts +135 -0
  58. package/dist/shard-contracts.js +13 -1
  59. package/dist/shard-router.d.ts +2 -1
  60. package/dist/shard-router.js +7 -0
  61. package/dist/shard-supervisor.js +43 -9
  62. package/dist/shard-worker.d.ts +5 -2
  63. package/dist/shard-worker.js +41 -5
  64. package/dist/supervisor-cloud-directory.d.ts +9 -3
  65. package/dist/supervisor-cloud-directory.js +8 -12
  66. package/dist/trivia-data-provider.d.ts +11 -0
  67. package/dist/trivia-data-provider.js +46 -26
  68. package/dist/verification-service.d.ts +2 -2
  69. package/dist/verification-service.js +6 -3
  70. package/documentation/README.md +5 -3
  71. package/documentation/examples/.env.example +15 -11
  72. package/documentation/guides/getting-started.md +29 -12
  73. package/documentation/guides/privacy-and-support.md +6 -5
  74. package/documentation/guides/self-hosting.md +16 -11
  75. package/documentation/modules/auto-moderation.md +7 -5
  76. package/documentation/modules/building-a-module.md +48 -2
  77. package/documentation/modules/moderation.md +5 -5
  78. package/documentation/modules/publishing-a-module.md +6 -4
  79. package/documentation/modules/testing-a-module.md +39 -4
  80. package/documentation/reference/environment.md +43 -9
  81. package/documentation/runbooks/backup-and-recovery.md +26 -7
  82. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  83. 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 { type DatabasePool } from "@helyx/database";
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
- database: DatabasePool;
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
- new InstallationRepository(control.database).isModuleEnabled(input.guildId, input.moduleId),
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 new InstallationRepository(control.database).isModuleEnabled(input.guildId, input.moduleId);
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 installed module versions",
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 modules = [...input.modules].sort((left, right) => left.id.localeCompare(right.id));
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
- "### Modules",
138
- ...moduleLines,
139
- ...(componentCount > visibleComponentCount
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
  },
@@ -4,6 +4,7 @@ export declare class DiscordOwnedMessageDeletionService implements DiscordOwnedM
4
4
  #private;
5
5
  private readonly client;
6
6
  constructor(client: Client);
7
+ isReady(): boolean;
7
8
  addOwnedMessageReaction(input: {
8
9
  guildId: string;
9
10
  channelId: string;
@@ -6,6 +6,9 @@ export class DiscordOwnedMessageDeletionService {
6
6
  constructor(client) {
7
7
  this.client = client;
8
8
  }
9
+ isReady() {
10
+ return this.client.isReady();
11
+ }
9
12
  async addOwnedMessageReaction(input) {
10
13
  const emoji = prepareEmojiIdentifier(input.emojiIdentifier);
11
14
  const message = await this.#fetchOwnedMessage(input);
@@ -1,4 +1,5 @@
1
- import { type GuildCommandBudgetClass, type GuildCommandHealth, type GuildCommandHistoryRecord, type GuildCommandRequestRecord, type GuildCommandRequestSource, type GuildCommandStateRecord, type GuildCommandVisibilityProfile } from "@helyx/database";
1
+ import { GuildCommandRepository, InstallationRepository, type GuildCommandBudgetClass, type GuildCommandHealth, type GuildCommandHistoryRecord, type GuildCommandRequestRecord, type GuildCommandRequestSource, type GuildCommandStateRecord, type GuildCommandVisibilityProfile } from "@helyx/database";
2
+ import { type GuildCommandRepository as MysqlGuildCommandRepository, type InstallationRepository as MysqlInstallationRepository } from "@helyx/database-mysql";
2
3
  import type { ModuleDefinition } from "@helyx/sdk";
3
4
  import type { Pool } from "pg";
4
5
  import type { GuildCommandWorkerHealth } from "./guild-command-worker.js";
@@ -78,9 +79,13 @@ export interface GuildCommandReadHandler {
78
79
  scheduledGuildCount: number;
79
80
  }>;
80
81
  }
82
+ type ReadCommandMethod = "getState" | "getActiveRequest" | "getCapacity" | "ensureState" | "schedule" | "recordStatusRefresh" | "changeVisibilityProfile" | "setMigrationHold" | "listHistory" | "getHealth";
83
+ export interface GuildCommandReadRepositories {
84
+ commands: Pick<GuildCommandRepository, ReadCommandMethod> & Pick<MysqlGuildCommandRepository, ReadCommandMethod>;
85
+ installations: Pick<InstallationRepository, "listActiveGuildIdsWithEnabledModule"> & Pick<MysqlInstallationRepository, "listActiveGuildIdsWithEnabledModule">;
86
+ }
81
87
  export declare class GuildCommandReadService implements GuildCommandReadHandler {
82
88
  #private;
83
- private readonly database;
84
89
  private readonly applicationId;
85
90
  private readonly workerHealth?;
86
91
  private readonly discordToken?;
@@ -88,7 +93,7 @@ export declare class GuildCommandReadService implements GuildCommandReadHandler
88
93
  private readonly writeGuildIds;
89
94
  private readonly writeAllGuilds;
90
95
  private readonly modules;
91
- constructor(database: Pool, applicationId: string, workerHealth?: (() => Promise<GuildCommandWorkerHealth> | undefined) | undefined, discordToken?: string | undefined, guildCommandWritesEnabled?: boolean, writeGuildIds?: readonly string[], writeAllGuilds?: boolean, modules?: readonly ModuleDefinition[]);
96
+ constructor(databaseOrRepositories: Pool | GuildCommandReadRepositories, applicationId: string, workerHealth?: (() => Promise<GuildCommandWorkerHealth> | undefined) | undefined, discordToken?: string | undefined, guildCommandWritesEnabled?: boolean, writeGuildIds?: readonly string[], writeAllGuilds?: boolean, modules?: readonly ModuleDefinition[]);
92
97
  previewFirstTimeSetup(moduleIds: string[]): Promise<{
93
98
  selectedModuleIds: string[];
94
99
  rootCount: number;
@@ -1,10 +1,10 @@
1
1
  import { GuildCommandRepository, GuildCommandRevisionConflictError, InstallationRepository, } from "@helyx/database";
2
+ import { GuildCommandRevisionConflictError as MysqlGuildCommandRevisionConflictError, } from "@helyx/database-mysql";
2
3
  import { isCustomerSelectableModule } from "@helyx/module-manifest";
3
4
  import { createHash } from "node:crypto";
4
5
  import { REST, Routes } from "discord.js";
5
6
  import { guildCommandDefinitionHash, projectGuildCommands, } from "./guild-command-projection.js";
6
7
  export class GuildCommandReadService {
7
- database;
8
8
  applicationId;
9
9
  workerHealth;
10
10
  discordToken;
@@ -13,8 +13,8 @@ export class GuildCommandReadService {
13
13
  writeAllGuilds;
14
14
  modules;
15
15
  #repository;
16
- constructor(database, applicationId, workerHealth, discordToken, guildCommandWritesEnabled = false, writeGuildIds = [], writeAllGuilds = false, modules = []) {
17
- this.database = database;
16
+ #installations;
17
+ constructor(databaseOrRepositories, applicationId, workerHealth, discordToken, guildCommandWritesEnabled = false, writeGuildIds = [], writeAllGuilds = false, modules = []) {
18
18
  this.applicationId = applicationId;
19
19
  this.workerHealth = workerHealth;
20
20
  this.discordToken = discordToken;
@@ -22,7 +22,14 @@ export class GuildCommandReadService {
22
22
  this.writeGuildIds = writeGuildIds;
23
23
  this.writeAllGuilds = writeAllGuilds;
24
24
  this.modules = modules;
25
- this.#repository = new GuildCommandRepository(this.database);
25
+ const repositories = "commands" in databaseOrRepositories
26
+ ? databaseOrRepositories
27
+ : {
28
+ commands: new GuildCommandRepository(databaseOrRepositories),
29
+ installations: new InstallationRepository(databaseOrRepositories),
30
+ };
31
+ this.#repository = repositories.commands;
32
+ this.#installations = repositories.installations;
26
33
  }
27
34
  previewFirstTimeSetup(moduleIds) {
28
35
  const requested = new Set(moduleIds);
@@ -97,11 +104,10 @@ export class GuildCommandReadService {
97
104
  return this.getStatus(input.guildId);
98
105
  }
99
106
  async reconcileModuleAvailability(input) {
100
- const installations = new InstallationRepository(this.database);
101
107
  let cursor;
102
108
  let scheduledGuildCount = 0;
103
109
  do {
104
- const page = await installations.listActiveGuildIdsWithEnabledModule({
110
+ const page = await this.#installations.listActiveGuildIdsWithEnabledModule({
105
111
  moduleId: input.moduleId,
106
112
  ...(cursor ? { cursor } : {}),
107
113
  limit: 500,
@@ -152,7 +158,8 @@ export class GuildCommandReadService {
152
158
  });
153
159
  }
154
160
  catch (error) {
155
- if (!(error instanceof GuildCommandRevisionConflictError))
161
+ if (!(error instanceof GuildCommandRevisionConflictError) &&
162
+ !(error instanceof MysqlGuildCommandRevisionConflictError))
156
163
  throw error;
157
164
  }
158
165
  return this.getStatus(input.guildId);
@@ -1,16 +1,23 @@
1
1
  import { GuildCommandRepository, InstallationRepository, RuntimeStateRepository, type DatabasePool } from "@helyx/database";
2
- type ReleaseInstallationRepository = Pick<InstallationRepository, "listActiveGuilds">;
3
- type ReleaseCommandRepository = Pick<GuildCommandRepository, "getState" | "schedule">;
4
- type ReleaseRuntimeRepository = Pick<RuntimeStateRepository, "get" | "compareAndSet">;
5
- export declare function scheduleGuildCommandRelease(input: {
2
+ import type { GuildCommandRepository as MysqlGuildCommandRepository, InstallationRepository as MysqlInstallationRepository, RuntimeStateRepository as MysqlRuntimeStateRepository } from "@helyx/database-mysql";
3
+ type ReleaseInstallationRepository = Pick<InstallationRepository, "listActiveGuilds"> & Pick<MysqlInstallationRepository, "listActiveGuilds">;
4
+ type ReleaseCommandRepository = Pick<GuildCommandRepository, "getState" | "schedule"> & Pick<MysqlGuildCommandRepository, "getState" | "schedule">;
5
+ type ReleaseRuntimeRepository = Pick<RuntimeStateRepository, "get" | "compareAndSet"> & Pick<MysqlRuntimeStateRepository, "get" | "compareAndSet">;
6
+ type ReleaseRepositories = {
7
+ installations: ReleaseInstallationRepository;
8
+ commands: ReleaseCommandRepository;
9
+ runtime: ReleaseRuntimeRepository;
10
+ };
11
+ type ReleasePersistence = ({
6
12
  database: DatabasePool;
13
+ } & Partial<ReleaseRepositories>) | ({
14
+ database?: never;
15
+ } & ReleaseRepositories);
16
+ export declare function scheduleGuildCommandRelease(input: {
7
17
  applicationId: string;
8
18
  releaseId: string;
9
19
  staggerMs?: number;
10
- installations?: ReleaseInstallationRepository;
11
- commands?: ReleaseCommandRepository;
12
- runtime?: ReleaseRuntimeRepository;
13
- }): Promise<{
20
+ } & ReleasePersistence): Promise<{
14
21
  scheduled: number;
15
22
  skippedIncomplete: number;
16
23
  replay: boolean;
@@ -6,9 +6,16 @@ export async function scheduleGuildCommandRelease(input) {
6
6
  if (!RELEASE_ID.test(input.releaseId))
7
7
  throw new Error("Guild command release ID is invalid");
8
8
  const markerKey = `guild-commands:release:${input.applicationId}:${input.releaseId}`;
9
- const installations = input.installations ?? new InstallationRepository(input.database);
10
- const commands = input.commands ?? new GuildCommandRepository(input.database);
11
- const runtime = input.runtime ?? new RuntimeStateRepository(input.database);
9
+ const database = input.database;
10
+ const installations = input.installations ??
11
+ (database ? new InstallationRepository(database) : undefined);
12
+ const commands = input.commands ??
13
+ (database ? new GuildCommandRepository(database) : undefined);
14
+ const runtime = input.runtime ??
15
+ (database ? new RuntimeStateRepository(database) : undefined);
16
+ if (!installations || !commands || !runtime) {
17
+ throw new Error("Guild command release requires a database or all three repositories");
18
+ }
12
19
  if (await runtime.get(markerKey))
13
20
  return { scheduled: 0, skippedIncomplete: 0, replay: true };
14
21
  const guilds = await installations.listActiveGuilds();