@helyx/bot 0.4.24 → 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,17 @@
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
+
9
+ ## 0.4.25
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.4.24
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -23,6 +23,10 @@ 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 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.
29
+
26
30
  The always-available guild-only `/bug` command can be used by any guild member.
27
31
  Hosted deployments submit through the dedicated `HELYX_SUPPORT_SERVICE_SECRET`;
28
32
  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 { BotControlHandler } from "./control-server.js";
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
@@ -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),
@@ -718,6 +719,7 @@ function connectorCapabilities() {
718
719
  return [
719
720
  "installation.health.read.v1",
720
721
  "guild.state.read.v1",
722
+ "guild.module.public.read.v1",
721
723
  "guild.module.status.write.v1",
722
724
  "guild.module.configuration.write.v1",
723
725
  "guild.module.permissions.write.v1",
@@ -786,7 +788,19 @@ function connectedMessage(input) {
786
788
  payload: input.payload,
787
789
  });
788
790
  }
789
- function mapControlError(error) {
791
+ export function mapControlError(error) {
792
+ if (error instanceof BotControlTransportError) {
793
+ if (error.status === 400)
794
+ return "bad_request";
795
+ if (error.status === 403)
796
+ return "forbidden";
797
+ if (error.status === 404)
798
+ return "not_found";
799
+ if (error.status === 409)
800
+ return "conflict";
801
+ if (error.status >= 502)
802
+ return "temporarily_unavailable";
803
+ }
790
804
  const name = error instanceof Error ? error.name : "";
791
805
  if (name.includes("NotFound"))
792
806
  return "not_found";
@@ -798,7 +812,7 @@ function mapControlError(error) {
798
812
  return "temporarily_unavailable";
799
813
  return "internal_error";
800
814
  }
801
- function safeControlMessage(error) {
815
+ export function safeControlMessage(error) {
802
816
  const code = mapControlError(error);
803
817
  if (code === "not_found")
804
818
  return "The requested Helyx resource was not found";
@@ -808,6 +822,8 @@ function safeControlMessage(error) {
808
822
  return error instanceof Error
809
823
  ? error.message.slice(0, 300)
810
824
  : "The requested change was not valid";
825
+ if (code === "forbidden")
826
+ return "You do not have permission to make this change";
811
827
  if (code === "temporarily_unavailable")
812
828
  return "The self-hosted bot is temporarily unavailable";
813
829
  return "The self-hosted bot could not complete the request";
@@ -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/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, 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, 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,
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
@@ -484,6 +492,9 @@ export function gatewayIntentsForModules(modules, messageContentEnabled = false)
484
492
  module.events?.guildMemberRemove?.length)) {
485
493
  intents.push(GatewayIntentBits.GuildMembers);
486
494
  }
495
+ if (modules.some((module) => module.events?.guildVoiceSession?.length)) {
496
+ intents.push(GatewayIntentBits.GuildVoiceStates);
497
+ }
487
498
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
488
499
  module.events?.guildEmojiUpdate?.length)) {
489
500
  intents.push(GatewayIntentBits.GuildExpressions);
@@ -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
  }
@@ -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, 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, 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,
242
+ modules: [
243
+ ...featureModules,
244
+ gamingStatisticsRecordOwner,
245
+ achievementContributionRecordOwner,
246
+ ],
238
247
  })
239
248
  : undefined);
240
249
  cloudConnector?.setControlHandler(new ShardControlRouter({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.24",
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",