@helyx/bot 0.4.25 → 0.4.26

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.26
4
+
5
+ ### Patch Changes
6
+
7
+ - Require the database runner that installs packaged module migration seeds before runtime readiness.
8
+
3
9
  ## 0.4.25
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -23,11 +23,9 @@ resume secure weighted-selection checkpoints without exposing the selection
23
23
  key to a module. Keep it stable across deployments; do not rotate it while a
24
24
  giveaway is drawing.
25
25
 
26
- Trivia uses the operator-only `HELYX_TRIVIA_API_KEY`. Commercial deployments
27
- need a paid The Trivia API subscription. Guess reuses saved Multiple Choice
28
- questions and checks answers locally, so it does not require the provider's
29
- Complete subscription. Local development may exercise Multiple Choice without
30
- a key; production never enables the provider adapter without one.
26
+ Trivia uses questions already imported into PostgreSQL. Multiple Choice and
27
+ Guess both run entirely from the saved question catalogue, and the bot runtime
28
+ does not call an external trivia provider.
31
29
 
32
30
  The always-available guild-only `/bug` command can be used by any guild member.
33
31
  Hosted deployments submit through the dedicated `HELYX_SUPPORT_SERVICE_SECRET`;
@@ -719,6 +719,7 @@ function connectorCapabilities() {
719
719
  return [
720
720
  "installation.health.read.v1",
721
721
  "guild.state.read.v1",
722
+ "guild.module.public.read.v1",
722
723
  "guild.module.status.write.v1",
723
724
  "guild.module.configuration.write.v1",
724
725
  "guild.module.permissions.write.v1",
@@ -25,6 +25,19 @@ export class CloudControlDispatcher {
25
25
  result = await this.control.getGuildState(input.guildId);
26
26
  break;
27
27
  }
28
+ case "guild.module.public.read.v1": {
29
+ const input = parseOperationInput(request.operation, request.input);
30
+ if (request.actor.kind !== "public")
31
+ throw new CloudControlDeniedError("public_actor_required");
32
+ result = await this.control.readPublicModuleProjection({
33
+ guildId: input.guildId,
34
+ moduleId: input.moduleId,
35
+ ...(input.memberUserId ? { subjectUserId: input.memberUserId } : {}),
36
+ ...(input.cursor ? { cursor: input.cursor } : {}),
37
+ limit: input.limit,
38
+ });
39
+ break;
40
+ }
28
41
  case "guild.module.status.write.v1": {
29
42
  const input = parseOperationInput(request.operation, request.input);
30
43
  result = await this.control.setModuleStatus({
@@ -1,5 +1,5 @@
1
1
  import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
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";
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 PublicAchievementProjectionPage, type ServiceAccess } from "@helyx/sdk";
3
3
  import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
4
4
  import { type Client } from "discord.js";
5
5
  import { type FastifyInstance } from "fastify";
@@ -43,6 +43,7 @@ interface DashboardModuleDefinition {
43
43
  commands: DashboardCommandDefinition[];
44
44
  logEvents: DashboardLogEventDefinition[];
45
45
  actions: DashboardActionDefinition[];
46
+ achievementTriggers: NonNullable<ModuleDefinition["manifest"]["contributions"]["achievementTriggers"]>;
46
47
  resources: readonly DashboardResourceDefinition[];
47
48
  }
48
49
  interface DashboardLoggingPolicy {
@@ -72,6 +73,14 @@ export interface BotControlGuildResources {
72
73
  colour: string;
73
74
  }>;
74
75
  }
76
+ export interface BotPublicModuleProjection {
77
+ server: {
78
+ id: string;
79
+ name: string;
80
+ memberCount: number;
81
+ };
82
+ projection: PublicAchievementProjectionPage;
83
+ }
75
84
  export type BotGuildPresenceState = "installed" | "not_installed" | "unknown" | "unavailable";
76
85
  export interface BotGuildPresenceObservation {
77
86
  guildId: string;
@@ -81,6 +90,11 @@ export interface BotGuildPresenceObservation {
81
90
  export interface BotControlGuildResourceDirectory {
82
91
  listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
83
92
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
93
+ getGuildPublicSummary(guildId: string): Promise<{
94
+ id: string;
95
+ name: string;
96
+ memberCount: number;
97
+ }>;
84
98
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
85
99
  messageId: string;
86
100
  }>;
@@ -95,6 +109,11 @@ export declare class DiscordGuildResourceDirectory implements BotControlGuildRes
95
109
  constructor(client: Client);
96
110
  listGuildPresence(guildIds: readonly string[]): Promise<BotGuildPresenceObservation[]>;
97
111
  getGuildResources(guildId: string): Promise<BotControlGuildResources>;
112
+ getGuildPublicSummary(guildId: string): Promise<{
113
+ id: string;
114
+ name: string;
115
+ memberCount: number;
116
+ }>;
98
117
  sendToChannel(channelId: string, response: MessageResponse): Promise<{
99
118
  messageId: string;
100
119
  }>;
@@ -134,6 +153,13 @@ export interface BotControlHandler {
134
153
  correlationId: string;
135
154
  }): Promise<BotGuildPresenceObservation[]>;
136
155
  getGuildState(guildId: string): Promise<BotControlState>;
156
+ readPublicModuleProjection(input: {
157
+ guildId: string;
158
+ moduleId: string;
159
+ subjectUserId?: string;
160
+ cursor?: string;
161
+ limit: number;
162
+ }): Promise<BotPublicModuleProjection>;
137
163
  canAccessManagedResource(input: {
138
164
  guildId: string;
139
165
  moduleId: string;
@@ -328,6 +354,13 @@ export declare class BotControlService implements BotControlHandler {
328
354
  cursor?: string;
329
355
  }): Promise<PrivacyErasePage>;
330
356
  getGuildState(guildId: string): Promise<BotControlState>;
357
+ readPublicModuleProjection(input: {
358
+ guildId: string;
359
+ moduleId: string;
360
+ subjectUserId?: string;
361
+ cursor?: string;
362
+ limit: number;
363
+ }): Promise<BotPublicModuleProjection>;
331
364
  canAccessManagedResource(input: {
332
365
  guildId: string;
333
366
  moduleId: string;
@@ -155,6 +155,17 @@ export class DiscordGuildResourceDirectory {
155
155
  throw error;
156
156
  }
157
157
  }
158
+ async getGuildPublicSummary(guildId) {
159
+ try {
160
+ const guild = await this.client.guilds.fetch(guildId);
161
+ return { id: guild.id, name: guild.name, memberCount: guild.memberCount };
162
+ }
163
+ catch (error) {
164
+ if (isUnknownGuildError(error))
165
+ throw new BotControlNotFoundError();
166
+ throw error;
167
+ }
168
+ }
158
169
  sendToChannel(channelId, response) {
159
170
  return sendDiscordMessageToChannel(this.client, channelId, response);
160
171
  }
@@ -414,6 +425,39 @@ export class BotControlService {
414
425
  logging,
415
426
  };
416
427
  }
428
+ async readPublicModuleProjection(input) {
429
+ const module = this.#requireModule(input.moduleId);
430
+ if (!module.publicProjection)
431
+ throw new BotControlNotFoundError("Public module projection was not found");
432
+ if (!(await new InstallationRepository(this.database).isModuleEnabled(input.guildId, input.moduleId)))
433
+ throw new BotControlValidationError("This module is not enabled");
434
+ if (input.subjectUserId) {
435
+ if (!this.#services.has(HELYX_SERVICE_NAMES.discordResources))
436
+ throw new BotControlTransportError(503, "Discord membership confirmation is unavailable");
437
+ const [member] = await this.#services
438
+ .get(HELYX_SERVICE_NAMES.discordResources)
439
+ .resolveMemberSnapshots({
440
+ guildId: input.guildId,
441
+ userIds: [input.subjectUserId],
442
+ });
443
+ if (!member?.exists)
444
+ throw new BotControlForbiddenError("Server membership is required");
445
+ }
446
+ const [server, projection] = await Promise.all([
447
+ this.resources.getGuildPublicSummary(input.guildId),
448
+ module.publicProjection.read({
449
+ guildId: input.guildId,
450
+ ...(input.subjectUserId
451
+ ? { subjectUserId: input.subjectUserId }
452
+ : {}),
453
+ services: this.#services,
454
+ }, {
455
+ ...(input.cursor ? { cursor: input.cursor } : {}),
456
+ limit: Math.min(10, Math.max(1, input.limit)),
457
+ }),
458
+ ]);
459
+ return { server, projection };
460
+ }
417
461
  async canAccessManagedResource(input) {
418
462
  try {
419
463
  const { module } = await this.#prepareManagedResource(input);
@@ -1095,6 +1139,26 @@ export async function createBotControlServer(input) {
1095
1139
  const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1096
1140
  return input.service.getGuildState(guildId);
1097
1141
  });
1142
+ server.get("/v1/guilds/:guildId/modules/:moduleId/public-projection", async (request) => {
1143
+ const params = z
1144
+ .object({ guildId: snowflake, moduleId })
1145
+ .strict()
1146
+ .parse(request.params);
1147
+ const query = z
1148
+ .object({
1149
+ subjectUserId: snowflake.optional(),
1150
+ cursor: z.string().min(1).max(2_048).optional(),
1151
+ limit: z.coerce.number().int().min(1).max(10).default(10),
1152
+ })
1153
+ .strict()
1154
+ .parse(request.query);
1155
+ return input.service.readPublicModuleProjection({
1156
+ ...params,
1157
+ ...(query.subjectUserId ? { subjectUserId: query.subjectUserId } : {}),
1158
+ ...(query.cursor ? { cursor: query.cursor } : {}),
1159
+ limit: query.limit,
1160
+ });
1161
+ });
1098
1162
  server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/access", async (request) => {
1099
1163
  const params = z
1100
1164
  .object({
@@ -1602,6 +1666,7 @@ function mapDashboardModule(module) {
1602
1666
  commands: dashboardCommands(module),
1603
1667
  logEvents: (module.logging?.events ?? []).map((event) => ({ ...event })),
1604
1668
  actions: (dashboard.actions ?? []).map((action) => ({ ...action })),
1669
+ achievementTriggers: (module.manifest.contributions?.achievementTriggers ?? []).map((trigger) => structuredClone(trigger)),
1605
1670
  resources: (dashboard.resources ?? []).map((resource) => structuredClone(resource)),
1606
1671
  };
1607
1672
  }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
3
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
- import { createPlatformServices, gamingStatisticsRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
5
+ import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
6
6
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
7
7
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
8
8
  import pino from "pino";
@@ -292,11 +292,19 @@ async function startUnshardedBot(processEnvironment) {
292
292
  ]),
293
293
  }, bootstrapLogger, serviceAccess);
294
294
  const moduleEvents = installModuleEvents(client, publicModules, bootstrapLogger, serviceAccess);
295
- const controlService = new BotControlService(database, [...publicModules, gamingStatisticsRecordOwner], guildResources, serviceAccess, privacyDatabase
295
+ const controlService = new BotControlService(database, [
296
+ ...publicModules,
297
+ gamingStatisticsRecordOwner,
298
+ achievementContributionRecordOwner,
299
+ ], guildResources, serviceAccess, privacyDatabase
296
300
  ? new PostgresPrivacyProviderExecutor({
297
301
  readPool: database,
298
302
  executorPool: privacyDatabase,
299
- modules: [...featureModules, gamingStatisticsRecordOwner],
303
+ modules: [
304
+ ...featureModules,
305
+ gamingStatisticsRecordOwner,
306
+ achievementContributionRecordOwner,
307
+ ],
300
308
  })
301
309
  : undefined, environment.DISCORD_INTERNAL_GUILD_ID &&
302
310
  environment.HELYX_SUPPORT_STAFF_CHANNEL_ID
@@ -169,6 +169,13 @@ export type ShardHealthSnapshot = z.infer<typeof shardHealthSnapshotSchema>;
169
169
  export declare const shardControlOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
170
170
  kind: z.ZodLiteral<"guild.state.get">;
171
171
  guildId: z.ZodString;
172
+ }, z.core.$strict>, z.ZodObject<{
173
+ kind: z.ZodLiteral<"guild.module.public.read">;
174
+ guildId: z.ZodString;
175
+ moduleId: z.ZodString;
176
+ subjectUserId: z.ZodOptional<z.ZodString>;
177
+ cursor: z.ZodOptional<z.ZodString>;
178
+ limit: z.ZodNumber;
172
179
  }, z.core.$strict>, z.ZodObject<{
173
180
  kind: z.ZodLiteral<"guild.presence.list">;
174
181
  guildIds: z.ZodArray<z.ZodString>;
@@ -478,6 +485,13 @@ export declare const shardControlRequestSchema: z.ZodObject<{
478
485
  operation: z.ZodDiscriminatedUnion<[z.ZodObject<{
479
486
  kind: z.ZodLiteral<"guild.state.get">;
480
487
  guildId: z.ZodString;
488
+ }, z.core.$strict>, z.ZodObject<{
489
+ kind: z.ZodLiteral<"guild.module.public.read">;
490
+ guildId: z.ZodString;
491
+ moduleId: z.ZodString;
492
+ subjectUserId: z.ZodOptional<z.ZodString>;
493
+ cursor: z.ZodOptional<z.ZodString>;
494
+ limit: z.ZodNumber;
481
495
  }, z.core.$strict>, z.ZodObject<{
482
496
  kind: z.ZodLiteral<"guild.presence.list">;
483
497
  guildIds: z.ZodArray<z.ZodString>;
@@ -1292,6 +1306,13 @@ export declare const supervisorMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObje
1292
1306
  operation: z.ZodDiscriminatedUnion<[z.ZodObject<{
1293
1307
  kind: z.ZodLiteral<"guild.state.get">;
1294
1308
  guildId: z.ZodString;
1309
+ }, z.core.$strict>, z.ZodObject<{
1310
+ kind: z.ZodLiteral<"guild.module.public.read">;
1311
+ guildId: z.ZodString;
1312
+ moduleId: z.ZodString;
1313
+ subjectUserId: z.ZodOptional<z.ZodString>;
1314
+ cursor: z.ZodOptional<z.ZodString>;
1315
+ limit: z.ZodNumber;
1295
1316
  }, z.core.$strict>, z.ZodObject<{
1296
1317
  kind: z.ZodLiteral<"guild.presence.list">;
1297
1318
  guildIds: z.ZodArray<z.ZodString>;
@@ -222,6 +222,16 @@ export const shardHealthSnapshotSchema = z
222
222
  });
223
223
  export const shardControlOperationSchema = z.discriminatedUnion("kind", [
224
224
  z.object({ kind: z.literal("guild.state.get"), guildId: snowflake }).strict(),
225
+ z
226
+ .object({
227
+ kind: z.literal("guild.module.public.read"),
228
+ guildId: snowflake,
229
+ moduleId,
230
+ subjectUserId: snowflake.optional(),
231
+ cursor: resourceCursor.optional(),
232
+ limit: z.number().int().min(1).max(10),
233
+ })
234
+ .strict(),
225
235
  z
226
236
  .object({
227
237
  kind: z.literal("guild.presence.list"),
@@ -901,6 +911,16 @@ export async function executeShardControlOperation(handler, operation) {
901
911
  });
902
912
  case "guild.state.get":
903
913
  return handler.getGuildState(operation.guildId);
914
+ case "guild.module.public.read":
915
+ return handler.readPublicModuleProjection({
916
+ guildId: operation.guildId,
917
+ moduleId: operation.moduleId,
918
+ ...(operation.subjectUserId
919
+ ? { subjectUserId: operation.subjectUserId }
920
+ : {}),
921
+ ...(operation.cursor ? { cursor: operation.cursor } : {}),
922
+ limit: operation.limit,
923
+ });
904
924
  case "guild.module.resource.access":
905
925
  return handler.canAccessManagedResource({
906
926
  guildId: operation.guildId,
@@ -16,6 +16,7 @@ export declare class ShardControlRouter implements BotControlHandler {
16
16
  notificationGuildId?: string;
17
17
  });
18
18
  getGuildState(guildId: string): ReturnType<BotControlHandler["getGuildState"]>;
19
+ readPublicModuleProjection(input: Parameters<BotControlHandler["readPublicModuleProjection"]>[0]): ReturnType<BotControlHandler["readPublicModuleProjection"]>;
19
20
  canAccessManagedResource(input: Parameters<BotControlHandler["canAccessManagedResource"]>[0]): ReturnType<BotControlHandler["canAccessManagedResource"]>;
20
21
  listGuildPresence(input: Parameters<BotControlHandler["listGuildPresence"]>[0]): ReturnType<BotControlHandler["listGuildPresence"]>;
21
22
  setModuleStatus(input: Parameters<BotControlHandler["setModuleStatus"]>[0]): ReturnType<BotControlHandler["setModuleStatus"]>;
@@ -10,6 +10,9 @@ export class ShardControlRouter {
10
10
  getGuildState(guildId) {
11
11
  return this.#route({ kind: "guild.state.get", guildId });
12
12
  }
13
+ readPublicModuleProjection(input) {
14
+ return this.#route({ kind: "guild.module.public.read", ...input });
15
+ }
13
16
  canAccessManagedResource(input) {
14
17
  return this.#route({
15
18
  kind: "guild.module.resource.access",
@@ -1,6 +1,6 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
2
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
- import { createPlatformServices, gamingStatisticsRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
3
+ import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
4
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
5
5
  import { Client, ShardingManager } from "discord.js";
6
6
  import { createHash, randomUUID } from "node:crypto";
@@ -169,6 +169,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
169
169
  observedAt: new Date().toISOString(),
170
170
  }))),
171
171
  getGuildResources: () => Promise.reject(new Error("Guild resources are worker-owned")),
172
+ getGuildPublicSummary: () => Promise.reject(new Error("Guild resources are worker-owned")),
172
173
  sendToChannel: () => Promise.reject(new Error("Guild resources are worker-owned")),
173
174
  deleteChannelMessage: () => Promise.reject(new Error("Guild resources are worker-owned")),
174
175
  editChannelMessage: () => Promise.reject(new Error("Guild resources are worker-owned")),
@@ -230,11 +231,19 @@ export async function startShardSupervisor(environment, processEnvironment, work
230
231
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
231
232
  if (numericWriteBehind)
232
233
  services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
233
- privacyService = new BotControlService(database, [...publicModules, gamingStatisticsRecordOwner], unavailableResources, new ServiceRegistry(services), privacyDatabase
234
+ privacyService = new BotControlService(database, [
235
+ ...publicModules,
236
+ gamingStatisticsRecordOwner,
237
+ achievementContributionRecordOwner,
238
+ ], unavailableResources, new ServiceRegistry(services), privacyDatabase
234
239
  ? new PostgresPrivacyProviderExecutor({
235
240
  readPool: database,
236
241
  executorPool: privacyDatabase,
237
- modules: [...featureModules, gamingStatisticsRecordOwner],
242
+ modules: [
243
+ ...featureModules,
244
+ gamingStatisticsRecordOwner,
245
+ achievementContributionRecordOwner,
246
+ ],
238
247
  })
239
248
  : undefined);
240
249
  cloudConnector?.setControlHandler(new ShardControlRouter({
@@ -41,7 +41,6 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
41
41
  HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: number;
42
42
  HELYX_PRIVACY_ENABLED: boolean;
43
43
  HELYX_MESSAGE_CONTENT_ENABLED: boolean;
44
- HELYX_TRIVIA_API_KEY: string | undefined;
45
44
  HELYX_CLOUD_CONNECT_ENABLED: boolean;
46
45
  HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
47
46
  HELYX_CLOUD_ENROLMENT_FILE: string;
@@ -10,7 +10,6 @@ import pino from "pino";
10
10
  import { createCloudManagementModule } from "./cloud-management-module.js";
11
11
  import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
12
12
  import { HttpVerificationService } from "./verification-service.js";
13
- import { HttpTriviaQuestionProvider } from "./trivia-provider.js";
14
13
  import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
15
14
  import { installGuildTracking, } from "./guilds.js";
16
15
  import { installInteractions, } from "./interactions.js";
@@ -183,14 +182,6 @@ export async function startShardWorker(processEnvironment = process.env, identit
183
182
  }));
184
183
  platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
185
184
  }
186
- if (environment.HELYX_TRIVIA_API_KEY ||
187
- environment.NODE_ENV !== "production") {
188
- platformServices.set(HELYX_SERVICE_NAMES.triviaQuestions, new HttpTriviaQuestionProvider({
189
- ...(environment.HELYX_TRIVIA_API_KEY
190
- ? { apiKey: environment.HELYX_TRIVIA_API_KEY }
191
- : {}),
192
- }));
193
- }
194
185
  numericWriteBehind =
195
186
  identity.scope === "coordinated"
196
187
  ? new WorkerNumericWriteBehindProxy(identity)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -51,7 +51,7 @@
51
51
  "@helyx/config": ">=0.5.0 <0.6.0",
52
52
  "@helyx/control-protocol": ">=0.3.0 <0.4.0",
53
53
  "@helyx/core": ">=0.1.0 <0.2.0",
54
- "@helyx/database": ">=0.7.0 <0.8.0",
54
+ "@helyx/database": ">=0.7.10 <0.8.0",
55
55
  "@helyx/framework": ">=0.1.0 <0.2.0",
56
56
  "@helyx/module-manifest": ">=0.6.0 <0.7.0",
57
57
  "@helyx/platform": ">=0.4.0 <0.5.0",
@@ -1,15 +0,0 @@
1
- import { type TriviaProviderFailureCode, type TriviaQuestion, type TriviaQuestionProvider } from "@helyx/sdk";
2
- export interface HttpTriviaQuestionProviderOptions {
3
- apiKey?: string;
4
- fetchImplementation?: typeof fetch;
5
- }
6
- export declare class HttpTriviaQuestionProvider implements TriviaQuestionProvider {
7
- #private;
8
- constructor(options: HttpTriviaQuestionProviderOptions);
9
- getQuestions(input: Parameters<TriviaQuestionProvider["getQuestions"]>[0]): Promise<readonly TriviaQuestion[]>;
10
- health(): Promise<{
11
- status: "healthy" | "degraded" | "unavailable";
12
- safeCode?: TriviaProviderFailureCode;
13
- }>;
14
- }
15
- //# sourceMappingURL=trivia-provider.d.ts.map
@@ -1,123 +0,0 @@
1
- import { TRIVIA_CATEGORIES, TriviaProviderError, } from "@helyx/sdk";
2
- import { z } from "zod";
3
- const BASE_URL = "https://the-trivia-api.com/v2";
4
- const MAX_RESPONSE_BYTES = 256 * 1_024;
5
- const REQUEST_TIMEOUT_MS = 8_000;
6
- const questionSchema = z.object({
7
- category: z.enum(TRIVIA_CATEGORIES),
8
- id: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/u),
9
- tags: z.array(z.string().trim().min(1).max(100)).max(100),
10
- difficulty: z.enum(["easy", "medium", "hard"]),
11
- regions: z.array(z.string().trim().min(1).max(20)).max(100),
12
- isNiche: z.boolean(),
13
- question: z.object({ text: z.string().trim().min(1).max(2_000) }),
14
- correctAnswer: z.string().trim().min(1).max(500),
15
- incorrectAnswers: z.array(z.string().trim().min(1).max(500)).max(10),
16
- type: z.literal("text_choice"),
17
- });
18
- export class HttpTriviaQuestionProvider {
19
- #apiKey;
20
- #fetch;
21
- #lastFailure;
22
- constructor(options) {
23
- this.#apiKey = options.apiKey;
24
- this.#fetch = options.fetchImplementation ?? fetch;
25
- }
26
- async getQuestions(input) {
27
- if (!Number.isSafeInteger(input.limit) ||
28
- input.limit < 1 ||
29
- input.limit > 50)
30
- this.#fail("invalid_response");
31
- const query = new URLSearchParams({
32
- limit: input.isNiche === undefined ? String(input.limit) : "50",
33
- types: input.type,
34
- contentFilter: input.familyContentOnly ? "family" : "all",
35
- });
36
- if (input.category)
37
- query.set("categories", input.category);
38
- if (input.difficulty)
39
- query.set("difficulties", input.difficulty);
40
- const raw = await this.#request(`/questions?${query.toString()}`, "GET");
41
- const parsed = z.array(questionSchema).max(50).safeParse(raw);
42
- if (!parsed.success) {
43
- this.#fail("invalid_response");
44
- }
45
- const matching = parsed.data.filter((question) => question.type === input.type &&
46
- (input.isNiche === undefined || question.isNiche === input.isNiche));
47
- this.#lastFailure = undefined;
48
- return matching.slice(0, input.limit);
49
- }
50
- health() {
51
- return Promise.resolve(this.#lastFailure
52
- ? { status: "degraded", safeCode: this.#lastFailure }
53
- : { status: "healthy" });
54
- }
55
- async #request(path, method) {
56
- let response;
57
- try {
58
- response = await this.#fetch(`${BASE_URL}${path}`, {
59
- method,
60
- headers: {
61
- accept: "application/json",
62
- ...(this.#apiKey ? { "x-api-key": this.#apiKey } : {}),
63
- },
64
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
65
- });
66
- }
67
- catch (error) {
68
- this.#fail(error instanceof DOMException && error.name === "TimeoutError"
69
- ? "timeout"
70
- : "unavailable");
71
- }
72
- if (!response.ok) {
73
- if (response.status === 429)
74
- this.#fail("rate_limited");
75
- if (response.status === 401)
76
- this.#fail("authentication");
77
- if (response.status === 403)
78
- this.#fail("subscription");
79
- this.#fail(response.status >= 500 ? "unavailable" : "invalid_response");
80
- }
81
- try {
82
- return JSON.parse(await readBoundedResponse(response));
83
- }
84
- catch (error) {
85
- if (error instanceof TriviaProviderError)
86
- throw error;
87
- this.#fail("invalid_response");
88
- }
89
- }
90
- #fail(code) {
91
- this.#lastFailure = code;
92
- throw new TriviaProviderError(code);
93
- }
94
- }
95
- async function readBoundedResponse(response) {
96
- const declared = Number(response.headers.get("content-length"));
97
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES)
98
- throw new TriviaProviderError("invalid_response");
99
- if (!response.body)
100
- return "";
101
- const reader = response.body.getReader();
102
- const chunks = [];
103
- let total = 0;
104
- try {
105
- for (;;) {
106
- const chunk = await reader.read();
107
- if (chunk.done)
108
- break;
109
- const boundedValue = chunk.value;
110
- if (!(boundedValue instanceof Uint8Array))
111
- throw new TriviaProviderError("invalid_response");
112
- total += boundedValue.byteLength;
113
- if (total > MAX_RESPONSE_BYTES)
114
- throw new TriviaProviderError("invalid_response");
115
- chunks.push(boundedValue);
116
- }
117
- }
118
- finally {
119
- reader.releaseLock();
120
- }
121
- return Buffer.concat(chunks, total).toString("utf8");
122
- }
123
- //# sourceMappingURL=trivia-provider.js.map