@helyx/bot 0.4.24 → 0.4.25
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 +6 -0
- package/README.md +6 -0
- package/dist/cloud-connector.d.ts +3 -1
- package/dist/cloud-connector.js +17 -2
- package/dist/events.js +68 -0
- package/dist/index.js +6 -3
- package/dist/interactions.js +15 -0
- package/dist/modules.js +1 -0
- package/dist/numeric-write-behind-proxy.js +3 -1
- package/dist/shard-supervisor.js +3 -3
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +9 -0
- package/dist/trivia-provider.d.ts +15 -0
- package/dist/trivia-provider.js +123 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.25
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Launch the shared gaming statistics foundation and the Achievements, Counting and Trivia module integrations, while removing retired Access Control and Counting storage through guarded migrations.
|
|
8
|
+
|
|
3
9
|
## 0.4.24
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -23,6 +23,12 @@ 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.
|
|
31
|
+
|
|
26
32
|
The always-available guild-only `/bug` command can be used by any guild member.
|
|
27
33
|
Hosted deployments submit through the dedicated `HELYX_SUPPORT_SERVICE_SECRET`;
|
|
28
34
|
paired self-hosted deployments use their authenticated Cloud Connect session
|
|
@@ -4,7 +4,7 @@ import { type CloudConnectorConsentRecord, type DatabasePool } from "@helyx/data
|
|
|
4
4
|
import type { Client } from "discord.js";
|
|
5
5
|
import type { Logger } from "pino";
|
|
6
6
|
import type { ConfiguredModulePackage } from "./modules.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type BotControlHandler } from "./control-server.js";
|
|
8
8
|
type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "recovery_required" | "revoked" | "stopped";
|
|
9
9
|
export interface CloudConnectorStatus {
|
|
10
10
|
state: ConnectorState;
|
|
@@ -80,5 +80,7 @@ export declare class CloudConnectorController implements CloudConnector {
|
|
|
80
80
|
export declare class CloudConnectorUserError extends Error {
|
|
81
81
|
constructor(message: string);
|
|
82
82
|
}
|
|
83
|
+
export declare function mapControlError(error: unknown): "not_found" | "conflict" | "bad_request" | "forbidden" | "temporarily_unavailable" | "internal_error";
|
|
84
|
+
export declare function safeControlMessage(error: unknown): string;
|
|
83
85
|
export {};
|
|
84
86
|
//# sourceMappingURL=cloud-connector.d.ts.map
|
package/dist/cloud-connector.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolve } from "node:path";
|
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { CloudControlDeniedError, CloudControlDispatcher, } from "./cloud-control-dispatcher.js";
|
|
9
|
+
import { BotControlTransportError, } from "./control-server.js";
|
|
9
10
|
const enrolmentFileSchema = z
|
|
10
11
|
.object({
|
|
11
12
|
schemaVersion: z.literal(1),
|
|
@@ -786,7 +787,19 @@ function connectedMessage(input) {
|
|
|
786
787
|
payload: input.payload,
|
|
787
788
|
});
|
|
788
789
|
}
|
|
789
|
-
function mapControlError(error) {
|
|
790
|
+
export function mapControlError(error) {
|
|
791
|
+
if (error instanceof BotControlTransportError) {
|
|
792
|
+
if (error.status === 400)
|
|
793
|
+
return "bad_request";
|
|
794
|
+
if (error.status === 403)
|
|
795
|
+
return "forbidden";
|
|
796
|
+
if (error.status === 404)
|
|
797
|
+
return "not_found";
|
|
798
|
+
if (error.status === 409)
|
|
799
|
+
return "conflict";
|
|
800
|
+
if (error.status >= 502)
|
|
801
|
+
return "temporarily_unavailable";
|
|
802
|
+
}
|
|
790
803
|
const name = error instanceof Error ? error.name : "";
|
|
791
804
|
if (name.includes("NotFound"))
|
|
792
805
|
return "not_found";
|
|
@@ -798,7 +811,7 @@ function mapControlError(error) {
|
|
|
798
811
|
return "temporarily_unavailable";
|
|
799
812
|
return "internal_error";
|
|
800
813
|
}
|
|
801
|
-
function safeControlMessage(error) {
|
|
814
|
+
export function safeControlMessage(error) {
|
|
802
815
|
const code = mapControlError(error);
|
|
803
816
|
if (code === "not_found")
|
|
804
817
|
return "The requested Helyx resource was not found";
|
|
@@ -808,6 +821,8 @@ function safeControlMessage(error) {
|
|
|
808
821
|
return error instanceof Error
|
|
809
822
|
? error.message.slice(0, 300)
|
|
810
823
|
: "The requested change was not valid";
|
|
824
|
+
if (code === "forbidden")
|
|
825
|
+
return "You do not have permission to make this change";
|
|
811
826
|
if (code === "temporarily_unavailable")
|
|
812
827
|
return "The self-hosted bot is temporarily unavailable";
|
|
813
828
|
return "The self-hosted bot could not complete the request";
|
package/dist/events.js
CHANGED
|
@@ -14,6 +14,10 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
14
14
|
false,
|
|
15
15
|
contribution,
|
|
16
16
|
})));
|
|
17
|
+
const guildVoiceSessionContributions = modules.flatMap((module) => (module.events?.guildVoiceSession ?? []).map((contribution) => ({
|
|
18
|
+
moduleId: module.manifest.id,
|
|
19
|
+
contribution,
|
|
20
|
+
})));
|
|
17
21
|
const messageClassificationRequested = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
|
|
18
22
|
const messageClassificationAvailable = messageClassificationRequested &&
|
|
19
23
|
client.options.intents.has(GatewayIntentBits.MessageContent);
|
|
@@ -63,6 +67,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
63
67
|
})));
|
|
64
68
|
if (privilegedMessageContributions.length === 0 &&
|
|
65
69
|
guildMessageContributions.length === 0 &&
|
|
70
|
+
guildVoiceSessionContributions.length === 0 &&
|
|
66
71
|
reactionContributions.length === 0 &&
|
|
67
72
|
reactionRemoveContributions.length === 0 &&
|
|
68
73
|
messageDeleteContributions.length === 0 &&
|
|
@@ -76,6 +81,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
76
81
|
guildEmojiUpdateContributions.length === 0)
|
|
77
82
|
return { stop: () => Promise.resolve(), queueDepth: () => 0 };
|
|
78
83
|
const queue = new BoundedGuildEventQueue(logger);
|
|
84
|
+
const voiceSessions = new Map();
|
|
79
85
|
const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
|
|
80
86
|
? new BoundedGuildEventQueue(logger)
|
|
81
87
|
: null;
|
|
@@ -129,6 +135,13 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
129
135
|
guildMessageQueue.enqueue(message.guildId, () => handleGuildMessageCreate(message));
|
|
130
136
|
});
|
|
131
137
|
}
|
|
138
|
+
if (guildVoiceSessionContributions.length > 0) {
|
|
139
|
+
client.on(Events.VoiceStateUpdate, (before, after) => {
|
|
140
|
+
if ((after.member ?? before.member)?.user.bot)
|
|
141
|
+
return;
|
|
142
|
+
queue.enqueue(after.guild.id, () => handleVoiceState(before, after));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
132
145
|
if (reactionContributions.length > 0) {
|
|
133
146
|
client.on(Events.MessageReactionAdd, (reaction, user, details) => {
|
|
134
147
|
const guildId = reaction.message.guildId;
|
|
@@ -271,6 +284,8 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
271
284
|
channelId: message.channelId,
|
|
272
285
|
...(parentChannelId ? { parentChannelId } : {}),
|
|
273
286
|
messageId: message.id,
|
|
287
|
+
isReply: message.reference?.messageId !== null &&
|
|
288
|
+
message.reference?.messageId !== undefined,
|
|
274
289
|
authorId: message.author.id,
|
|
275
290
|
authorDisplayName,
|
|
276
291
|
authorRoleIds,
|
|
@@ -514,11 +529,64 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
514
529
|
}
|
|
515
530
|
}
|
|
516
531
|
}
|
|
532
|
+
async function handleVoiceState(before, after) {
|
|
533
|
+
const memberId = after.id;
|
|
534
|
+
const key = `${after.guild.id}:${memberId}`;
|
|
535
|
+
const previous = voiceSessions.get(key);
|
|
536
|
+
const channelId = eligibleVoiceChannel(after);
|
|
537
|
+
if (previous && previous.channelId !== channelId) {
|
|
538
|
+
voiceSessions.delete(key);
|
|
539
|
+
const endedAt = new Date();
|
|
540
|
+
const durationSeconds = Math.max(1, Math.floor((endedAt.getTime() - previous.startedAt.getTime()) / 1_000));
|
|
541
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
542
|
+
for (const { moduleId, contribution } of guildVoiceSessionContributions) {
|
|
543
|
+
try {
|
|
544
|
+
if (!(await installations.isModuleEnabled(previous.guildId, moduleId)))
|
|
545
|
+
continue;
|
|
546
|
+
await contribution.execute({
|
|
547
|
+
serverId: previous.guildId,
|
|
548
|
+
channelId: previous.channelId,
|
|
549
|
+
memberId,
|
|
550
|
+
startedAt: previous.startedAt,
|
|
551
|
+
endedAt,
|
|
552
|
+
durationSeconds,
|
|
553
|
+
services,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
logger.warn({
|
|
558
|
+
event: "module.guild_voice_session_failed",
|
|
559
|
+
moduleId,
|
|
560
|
+
guildId: previous.guildId,
|
|
561
|
+
error: safeError(error),
|
|
562
|
+
}, "Module guild voice session event failed");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (channelId && (!previous || previous.channelId !== channelId)) {
|
|
567
|
+
voiceSessions.set(key, {
|
|
568
|
+
guildId: after.guild.id,
|
|
569
|
+
memberId,
|
|
570
|
+
channelId,
|
|
571
|
+
startedAt: new Date(),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function eligibleVoiceChannel(state) {
|
|
576
|
+
if (!state.channelId || !state.channel)
|
|
577
|
+
return null;
|
|
578
|
+
if (state.channel.type !== ChannelType.GuildVoice)
|
|
579
|
+
return null;
|
|
580
|
+
if (state.guild.afkChannelId === state.channelId)
|
|
581
|
+
return null;
|
|
582
|
+
return state.channelId;
|
|
583
|
+
}
|
|
517
584
|
return {
|
|
518
585
|
stop: async () => {
|
|
519
586
|
await privilegedEligibilityQueue?.stop();
|
|
520
587
|
await guildMessageQueue?.stop();
|
|
521
588
|
await queue.stop();
|
|
589
|
+
voiceSessions.clear();
|
|
522
590
|
},
|
|
523
591
|
queueDepth: () => queue.queueDepth() +
|
|
524
592
|
(privilegedEligibilityQueue?.queueDepth() ?? 0) +
|
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, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
|
|
5
|
+
import { createPlatformServices, gamingStatisticsRecordOwner, 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,11 @@ 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, guildResources, serviceAccess, privacyDatabase
|
|
295
|
+
const controlService = new BotControlService(database, [...publicModules, gamingStatisticsRecordOwner], guildResources, serviceAccess, privacyDatabase
|
|
296
296
|
? new PostgresPrivacyProviderExecutor({
|
|
297
297
|
readPool: database,
|
|
298
298
|
executorPool: privacyDatabase,
|
|
299
|
-
modules: featureModules,
|
|
299
|
+
modules: [...featureModules, gamingStatisticsRecordOwner],
|
|
300
300
|
})
|
|
301
301
|
: undefined, environment.DISCORD_INTERNAL_GUILD_ID &&
|
|
302
302
|
environment.HELYX_SUPPORT_STAFF_CHANNEL_ID
|
|
@@ -484,6 +484,9 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false)
|
|
|
484
484
|
module.events?.guildMemberRemove?.length)) {
|
|
485
485
|
intents.push(GatewayIntentBits.GuildMembers);
|
|
486
486
|
}
|
|
487
|
+
if (modules.some((module) => module.events?.guildVoiceSession?.length)) {
|
|
488
|
+
intents.push(GatewayIntentBits.GuildVoiceStates);
|
|
489
|
+
}
|
|
487
490
|
if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
|
|
488
491
|
module.events?.guildEmojiUpdate?.length)) {
|
|
489
492
|
intents.push(GatewayIntentBits.GuildExpressions);
|
package/dist/interactions.js
CHANGED
|
@@ -277,6 +277,20 @@ function addCommandOption(builder, option) {
|
|
|
277
277
|
.setRequired(option.required ?? false));
|
|
278
278
|
return;
|
|
279
279
|
}
|
|
280
|
+
if (option.type === "integer") {
|
|
281
|
+
builder.addIntegerOption((optionBuilder) => {
|
|
282
|
+
optionBuilder
|
|
283
|
+
.setName(option.name)
|
|
284
|
+
.setDescription(option.description)
|
|
285
|
+
.setRequired(option.required ?? false);
|
|
286
|
+
if (option.minimum !== undefined)
|
|
287
|
+
optionBuilder.setMinValue(option.minimum);
|
|
288
|
+
if (option.maximum !== undefined)
|
|
289
|
+
optionBuilder.setMaxValue(option.maximum);
|
|
290
|
+
return optionBuilder;
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
280
294
|
if (option.type === "user") {
|
|
281
295
|
builder.addUserOption((optionBuilder) => optionBuilder
|
|
282
296
|
.setName(option.name)
|
|
@@ -399,6 +413,7 @@ function createCommandContext(interaction, services) {
|
|
|
399
413
|
...createContext(interaction, services),
|
|
400
414
|
getStringOption: (name) => interaction.options.getString(name),
|
|
401
415
|
getBooleanOption: (name) => interaction.options.getBoolean(name),
|
|
416
|
+
getIntegerOption: (name) => interaction.options.getInteger(name),
|
|
402
417
|
getUserOption: (name) => {
|
|
403
418
|
const user = interaction.options.getUser(name);
|
|
404
419
|
return user
|
package/dist/modules.js
CHANGED
|
@@ -100,6 +100,7 @@ export function validateContributionParity(definition) {
|
|
|
100
100
|
const executableEvents = [
|
|
101
101
|
...(definition.events?.privilegedMessageCreate ?? []),
|
|
102
102
|
...(definition.events?.guildMessageCreate ?? []),
|
|
103
|
+
...(definition.events?.guildVoiceSession ?? []),
|
|
103
104
|
...(definition.events?.messageReactionAdd ?? []),
|
|
104
105
|
...(definition.events?.messageReactionRemove ?? []),
|
|
105
106
|
...(definition.events?.messageDelete ?? []),
|
|
@@ -275,8 +275,10 @@ function encodeRecord(input) {
|
|
|
275
275
|
result[field] = { type: "string", value };
|
|
276
276
|
else if (typeof value === "boolean")
|
|
277
277
|
result[field] = { type: "boolean", value };
|
|
278
|
-
else
|
|
278
|
+
else if (typeof value === "number")
|
|
279
279
|
result[field] = { type: "number", value };
|
|
280
|
+
else
|
|
281
|
+
throw new Error("Numeric write-behind values must be scalar");
|
|
280
282
|
}
|
|
281
283
|
return result;
|
|
282
284
|
}
|
package/dist/shard-supervisor.js
CHANGED
|
@@ -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, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
|
|
3
|
+
import { createPlatformServices, gamingStatisticsRecordOwner, 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";
|
|
@@ -230,11 +230,11 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
230
230
|
const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
|
|
231
231
|
if (numericWriteBehind)
|
|
232
232
|
services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
|
|
233
|
-
privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
|
|
233
|
+
privacyService = new BotControlService(database, [...publicModules, gamingStatisticsRecordOwner], unavailableResources, new ServiceRegistry(services), privacyDatabase
|
|
234
234
|
? new PostgresPrivacyProviderExecutor({
|
|
235
235
|
readPool: database,
|
|
236
236
|
executorPool: privacyDatabase,
|
|
237
|
-
modules: featureModules,
|
|
237
|
+
modules: [...featureModules, gamingStatisticsRecordOwner],
|
|
238
238
|
})
|
|
239
239
|
: undefined);
|
|
240
240
|
cloudConnector?.setControlHandler(new ShardControlRouter({
|
package/dist/shard-worker.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ 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;
|
|
44
45
|
HELYX_CLOUD_CONNECT_ENABLED: boolean;
|
|
45
46
|
HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
|
|
46
47
|
HELYX_CLOUD_ENROLMENT_FILE: string;
|
package/dist/shard-worker.js
CHANGED
|
@@ -10,6 +10,7 @@ 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";
|
|
13
14
|
import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
|
|
14
15
|
import { installGuildTracking, } from "./guilds.js";
|
|
15
16
|
import { installInteractions, } from "./interactions.js";
|
|
@@ -182,6 +183,14 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
182
183
|
}));
|
|
183
184
|
platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
|
|
184
185
|
}
|
|
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
|
+
}
|
|
185
194
|
numericWriteBehind =
|
|
186
195
|
identity.scope === "coordinated"
|
|
187
196
|
? new WorkerNumericWriteBehindProxy(identity)
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|