@helyx/bot 0.4.9 → 0.4.13

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/dist/guilds.d.ts CHANGED
@@ -2,5 +2,24 @@ import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
2
2
  import type { ModuleDefinition } from "@helyx/sdk";
3
3
  import { type Client } from "discord.js";
4
4
  import type { InteractionLogger } from "./interactions.js";
5
- export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership): (readyClient: Client<true>) => Promise<void>;
5
+ export interface GuildReconciliationHealth {
6
+ status: "pending" | "running" | "healthy" | "failed" | "stopped";
7
+ lastStartedAt?: string;
8
+ lastSucceededAt?: string;
9
+ durationMs?: number;
10
+ examinedRows: number;
11
+ refreshedGuilds: number;
12
+ removedGuilds: number;
13
+ rejectedFences: number;
14
+ unavailableGuilds: number;
15
+ failureReasonCode?: string | undefined;
16
+ nextScheduledAt?: string | undefined;
17
+ }
18
+ export interface GuildTrackingPreparation {
19
+ (readyClient: Client<true>): Promise<void>;
20
+ reconcile(reason: "ready" | "resume" | "periodic"): Promise<void>;
21
+ health(): GuildReconciliationHealth;
22
+ stop(): void;
23
+ }
24
+ export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number): GuildTrackingPreparation;
6
25
  //# sourceMappingURL=guilds.d.ts.map
package/dist/guilds.js CHANGED
@@ -1,7 +1,24 @@
1
1
  import { InstallationRepository, } from "@helyx/database";
2
2
  import { Events } from "discord.js";
3
- export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership) {
3
+ const RECONCILIATION_PAGE_SIZE = 250;
4
+ const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
5
+ export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount) {
6
+ if (!Number.isSafeInteger(reconciliationIntervalMs) ||
7
+ reconciliationIntervalMs < 60_000)
8
+ throw new Error("Guild reconciliation interval must be at least one minute");
4
9
  const repository = new InstallationRepository(database);
10
+ let prepared = false;
11
+ let stopped = false;
12
+ let reconciliation;
13
+ let timer;
14
+ let health = {
15
+ status: "pending",
16
+ examinedRows: 0,
17
+ refreshedGuilds: 0,
18
+ removedGuilds: 0,
19
+ rejectedFences: 0,
20
+ unavailableGuilds: 0,
21
+ };
5
22
  async function syncGuild(guild) {
6
23
  const record = await repository.syncGuild({
7
24
  guildId: guild.id,
@@ -39,10 +56,16 @@ export function installGuildTracking(client, database, modules, logger, enabledB
39
56
  client.on(Events.GuildDelete, (guild) => {
40
57
  void repository
41
58
  .markGuildRemoved(guild.id, ownership)
59
+ .then((changed) => {
60
+ logger.info({
61
+ event: "guild.removed",
62
+ outcome: changed ? "changed" : "rejected_or_duplicate",
63
+ }, "Discord guild removal observation persisted");
64
+ })
42
65
  .catch((error) => {
43
66
  logger.error({
44
67
  event: "guild.remove_failed",
45
- guildId: guild.id,
68
+ reasonCode: "database_write_failed",
46
69
  error: safeError(error),
47
70
  }, "Guild removal tracking failed");
48
71
  });
@@ -50,14 +73,131 @@ export function installGuildTracking(client, database, modules, logger, enabledB
50
73
  client.on(Events.GuildUnavailable, (guild) => {
51
74
  logger.warn({ event: "guild.unavailable", guildId: guild.id }, "Discord guild is temporarily unavailable; installation remains active");
52
75
  });
53
- return async (readyClient) => {
54
- for (const guild of readyClient.guilds.cache.values())
55
- await syncGuild(guild);
56
- logger.info({
57
- event: "guilds.synchronised",
58
- guildCount: readyClient.guilds.cache.size,
59
- }, "Discord guild installations synchronised");
76
+ async function runReconciliation(reason) {
77
+ if (stopped || !prepared || !client.isReady())
78
+ return;
79
+ if (reconciliation)
80
+ return reconciliation;
81
+ reconciliation = (async () => {
82
+ const startedAt = Date.now();
83
+ health = {
84
+ ...health,
85
+ status: "running",
86
+ lastStartedAt: new Date(startedAt).toISOString(),
87
+ failureReasonCode: undefined,
88
+ nextScheduledAt: undefined,
89
+ };
90
+ let examinedRows = 0;
91
+ let refreshedGuilds = 0;
92
+ let removedGuilds = 0;
93
+ let rejectedFences = 0;
94
+ const observedGuildIds = new Set(client.guilds.cache.keys());
95
+ const unavailableGuilds = [...client.guilds.cache.values()].filter((guild) => !guild.available).length;
96
+ try {
97
+ for (const guild of client.guilds.cache.values()) {
98
+ if (stopped)
99
+ return;
100
+ await syncGuild(guild);
101
+ refreshedGuilds += 1;
102
+ }
103
+ let cursor;
104
+ do {
105
+ if (stopped || !client.isReady())
106
+ return;
107
+ const page = await repository.listActiveGuildIdsForOwnership({
108
+ ...(ownership ? { ownership } : {}),
109
+ ...(ownership && shardCount !== undefined ? { shardCount } : {}),
110
+ ...(cursor ? { cursor } : {}),
111
+ limit: RECONCILIATION_PAGE_SIZE,
112
+ });
113
+ examinedRows += page.guildIds.length;
114
+ if (ownership)
115
+ await repository.adoptGuildsForReconciliation({
116
+ guildIds: page.guildIds,
117
+ ownership,
118
+ shardCount: shardCount,
119
+ });
120
+ const absent = page.guildIds.filter((guildId) => !observedGuildIds.has(guildId));
121
+ const result = await repository.markGuildsRemovedForReconciliation({
122
+ guildIds: absent,
123
+ ...(ownership ? { ownership } : {}),
124
+ });
125
+ removedGuilds += result.changed;
126
+ rejectedFences += result.rejectedFence;
127
+ cursor = page.nextCursor;
128
+ } while (cursor);
129
+ const completedAt = Date.now();
130
+ health = {
131
+ status: "healthy",
132
+ lastStartedAt: new Date(startedAt).toISOString(),
133
+ lastSucceededAt: new Date(completedAt).toISOString(),
134
+ durationMs: completedAt - startedAt,
135
+ examinedRows,
136
+ refreshedGuilds,
137
+ removedGuilds,
138
+ rejectedFences,
139
+ unavailableGuilds,
140
+ nextScheduledAt: new Date(completedAt + reconciliationIntervalMs).toISOString(),
141
+ };
142
+ logger.info({
143
+ event: "guild.reconciliation_completed",
144
+ reason,
145
+ durationMs: health.durationMs,
146
+ examinedRows,
147
+ refreshedGuilds,
148
+ removedGuilds,
149
+ rejectedFences,
150
+ unavailableGuilds,
151
+ }, "Discord guild presence reconciliation completed");
152
+ }
153
+ catch (error) {
154
+ health = {
155
+ status: "failed",
156
+ lastStartedAt: new Date(startedAt).toISOString(),
157
+ durationMs: Date.now() - startedAt,
158
+ examinedRows,
159
+ refreshedGuilds,
160
+ removedGuilds,
161
+ rejectedFences,
162
+ unavailableGuilds,
163
+ failureReasonCode: "reconciliation_failed",
164
+ nextScheduledAt: new Date(Date.now() + reconciliationIntervalMs).toISOString(),
165
+ };
166
+ logger.error({
167
+ event: "guild.reconciliation_failed",
168
+ reason,
169
+ reasonCode: health.failureReasonCode,
170
+ error: safeError(error),
171
+ }, "Discord guild presence reconciliation failed");
172
+ }
173
+ })().finally(() => {
174
+ reconciliation = undefined;
175
+ });
176
+ return reconciliation;
177
+ }
178
+ const prepare = async (readyClient) => {
179
+ if (stopped || !readyClient.isReady())
180
+ return;
181
+ prepared = true;
182
+ await runReconciliation("ready");
183
+ if (!timer) {
184
+ timer = setInterval(() => void runReconciliation("periodic"), reconciliationIntervalMs);
185
+ timer.unref();
186
+ }
187
+ };
188
+ client.on(Events.ShardResume, () => {
189
+ void runReconciliation("resume");
190
+ });
191
+ prepare.reconcile = runReconciliation;
192
+ prepare.health = () => ({ ...health });
193
+ prepare.stop = () => {
194
+ stopped = true;
195
+ if (timer)
196
+ clearInterval(timer);
197
+ timer = undefined;
198
+ health = { ...health, status: "stopped", nextScheduledAt: undefined };
60
199
  };
200
+ return prepare;
61
201
  }
62
202
  function safeError(error) {
63
203
  const value = error instanceof Error ? error : new Error(String(error));
package/dist/index.d.ts CHANGED
@@ -9,5 +9,5 @@ export interface BotHandle {
9
9
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
10
10
  export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
11
11
  export declare function readBotVersion(): string;
12
- export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[]): GatewayIntentBits[];
12
+ export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean): GatewayIntentBits[];
13
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
- import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
3
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, VerificationSetupRepository, } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
5
  import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
6
6
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
@@ -18,9 +18,10 @@ import { loadConfiguredModulePackages, platformServiceOptions, } from "./modules
18
18
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
19
19
  import { createPermissionsModule } from "./permissions-module.js";
20
20
  import { createManagementModule } from "./management-module.js";
21
- import { DiscordMemberRoleService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
21
+ import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordVerificationSetupService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
22
22
  import { DiscordModuleLoggingService } from "./module-logging.js";
23
23
  import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
24
+ import { HttpVerificationService } from "./verification-service.js";
24
25
  import { startShardSupervisor } from "./shard-supervisor.js";
25
26
  import { parseShardWorkerLaunchContract, startShardWorker, } from "./shard-worker.js";
26
27
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
@@ -29,6 +30,7 @@ import { createSupportModule } from "./support-module.js";
29
30
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
30
31
  import { CoreResourceAuthorizationService } from "./resource-authorization.js";
31
32
  import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
33
+ import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
32
34
  export { loadConfiguredModulePackages } from "./modules.js";
33
35
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
34
36
  export * from "./control-server.js";
@@ -86,15 +88,17 @@ async function startUnshardedBot(processEnvironment) {
86
88
  verifiedMigrationCount: migrationResult.verified.length,
87
89
  }, "Core database ready");
88
90
  const cleanupExpiredCoreRecords = async () => {
89
- const [sessions, rateLimits] = await Promise.all([
91
+ const [sessions, rateLimits, verificationAudit] = await Promise.all([
90
92
  new InteractionSessionRepository(database).deleteExpired(),
91
93
  new RateLimitRepository(database).deleteExpired(),
94
+ new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
92
95
  ]);
93
- if (sessions > 0 || rateLimits > 0) {
96
+ if (sessions > 0 || rateLimits > 0 || verificationAudit > 0) {
94
97
  bootstrapLogger.info({
95
98
  event: "database.retention_cleaned",
96
99
  interactionSessions: sessions,
97
100
  rateLimitWindows: rateLimits,
101
+ verificationAuditEvents: verificationAudit,
98
102
  }, "Expired core records removed");
99
103
  }
100
104
  };
@@ -125,7 +129,7 @@ async function startUnshardedBot(processEnvironment) {
125
129
  }, "Module databases ready");
126
130
  const featureModules = modulePackages.map(({ definition }) => definition);
127
131
  const client = new Client({
128
- intents: gatewayIntentsForModules(featureModules),
132
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED),
129
133
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
130
134
  allowedMentions: { parse: [] },
131
135
  });
@@ -234,15 +238,37 @@ async function startUnshardedBot(processEnvironment) {
234
238
  ...publicModules,
235
239
  ...(operationsModule ? [operationsModule] : []),
236
240
  ];
237
- const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
241
+ const platformServices = new Map();
242
+ const serviceAccess = new ServiceRegistry(platformServices);
243
+ for (const [name, service] of createPlatformServices(database, publicModules, {
244
+ ...platformServiceOptions(environment, publicModules),
245
+ activationServices: serviceAccess,
246
+ })) {
247
+ platformServices.set(name, service);
248
+ }
249
+ if (environment.HELYX_VERIFICATION_ENABLED &&
250
+ environment.HELYX_VERIFICATION_URL &&
251
+ environment.HELYX_VERIFICATION_INTERNAL_SECRET) {
252
+ platformServices.set(HELYX_SERVICE_NAMES.verification, new HttpVerificationService({
253
+ baseUrl: environment.HELYX_VERIFICATION_URL,
254
+ internalSecret: environment.HELYX_VERIFICATION_INTERNAL_SECRET,
255
+ installationId: environment.HELYX_VERIFICATION_INSTALLATION_ID,
256
+ publicSigningKeys: environment.HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS,
257
+ }));
258
+ platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
259
+ platformServices.set(HELYX_SERVICE_NAMES.verificationSetup, new DiscordVerificationSetupService(client, new VerificationSetupRepository(database)));
260
+ }
261
+ const numericWriteBehind = createLocalNumericWriteBehind(platformServices, publicModules);
262
+ platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
238
263
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
264
+ platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
239
265
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
266
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
240
267
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
241
268
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
242
269
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
243
270
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
244
271
  }
245
- const serviceAccess = new ServiceRegistry(platformServices);
246
272
  const runtime = createRuntime({
247
273
  modules,
248
274
  logger: bootstrapLogger,
@@ -255,7 +281,7 @@ async function startUnshardedBot(processEnvironment) {
255
281
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
256
282
  ...(operationsModule ? [operationsModule.manifest.id] : []),
257
283
  ]);
258
- const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds);
284
+ const prepareGuilds = installGuildTracking(client, database, publicModules, bootstrapLogger, enabledCoreModuleIds, undefined, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000);
259
285
  const commandsReady = installInteractions(client, modules, {
260
286
  ...(environment.DISCORD_INTERNAL_GUILD_ID
261
287
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -287,6 +313,7 @@ async function startUnshardedBot(processEnvironment) {
287
313
  if (shuttingDown)
288
314
  return;
289
315
  shuttingDown = true;
316
+ prepareGuilds.stop();
290
317
  process.off("SIGINT", onSigint);
291
318
  process.off("SIGTERM", onSigterm);
292
319
  if (retentionCleanupTimer)
@@ -305,14 +332,29 @@ async function startUnshardedBot(processEnvironment) {
305
332
  process.exitCode = 1;
306
333
  bootstrapLogger.error({ event: "control.stop_failed", error: safeError(error) }, "Private bot control shutdown failed");
307
334
  });
308
- try {
309
- await moduleEvents.stop();
310
- await runtime.stop();
311
- }
312
- catch (error) {
335
+ await moduleEvents.stop().catch((error) => {
336
+ process.exitCode = 1;
337
+ bootstrapLogger.error({ event: "module_events.stop_failed", error: safeError(error) }, "Module event shutdown failed");
338
+ });
339
+ await runtime.stop().catch((error) => {
313
340
  process.exitCode = 1;
314
341
  bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
315
- }
342
+ });
343
+ await numericWriteBehind
344
+ .stop()
345
+ .then(({ drained }) => {
346
+ if (drained)
347
+ return;
348
+ process.exitCode = 1;
349
+ bootstrapLogger.error({ event: "numeric_write_behind.shutdown_not_drained" }, "Numeric write-behind exceeded its bounded shutdown drain");
350
+ })
351
+ .catch((error) => {
352
+ process.exitCode = 1;
353
+ bootstrapLogger.error({
354
+ event: "numeric_write_behind.shutdown_failed",
355
+ error: safeError(error),
356
+ }, "Numeric write-behind shutdown failed");
357
+ });
316
358
  await client.destroy();
317
359
  await closeDatabase();
318
360
  }
@@ -364,13 +406,26 @@ async function startUnshardedBot(processEnvironment) {
364
406
  await cloudConnector.stop().catch(() => undefined);
365
407
  if (controlServer)
366
408
  await controlServer.close().catch(() => undefined);
367
- try {
368
- await moduleEvents.stop();
369
- await runtime.stop();
370
- }
371
- catch (stopError) {
409
+ await moduleEvents.stop().catch((stopError) => {
410
+ bootstrapLogger.error({
411
+ event: "module_events.cleanup_failed",
412
+ error: safeError(stopError),
413
+ }, "Module event cleanup failed");
414
+ });
415
+ await runtime.stop().catch((stopError) => {
372
416
  bootstrapLogger.error({ event: "runtime.cleanup_failed", error: safeError(stopError) }, "Runtime cleanup failed");
373
- }
417
+ });
418
+ const numericCleanup = await numericWriteBehind
419
+ .stop()
420
+ .catch((stopError) => {
421
+ bootstrapLogger.error({
422
+ event: "numeric_write_behind.cleanup_failed",
423
+ error: safeError(stopError),
424
+ }, "Numeric write-behind cleanup failed");
425
+ return { drained: false };
426
+ });
427
+ if (!numericCleanup.drained)
428
+ bootstrapLogger.error({ event: "numeric_write_behind.cleanup_not_drained" }, "Numeric write-behind cleanup did not drain");
374
429
  await client.destroy();
375
430
  throw error;
376
431
  }
@@ -407,16 +462,27 @@ function deploymentReference(processEnvironment) {
407
462
  processEnvironment.GIT_COMMIT_SHA;
408
463
  return value?.trim().slice(0, 12) || undefined;
409
464
  }
410
- export function gatewayIntentsForModules(modules) {
465
+ export function gatewayIntentsForModules(modules, messageContentEnabled = false) {
411
466
  const intents = [GatewayIntentBits.Guilds];
412
467
  if (modules.some((module) => module.events?.messageReactionAdd?.length ||
413
468
  module.events?.messageReactionRemove?.length)) {
414
469
  intents.push(GatewayIntentBits.GuildMessageReactions);
415
470
  }
416
- if (modules.some((module) => module.events?.messageDelete?.length)) {
471
+ const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.length);
472
+ const requiresMessageContent = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
473
+ if (((hasPrivilegedMessageCreate || requiresMessageContent) &&
474
+ messageContentEnabled) ||
475
+ modules.some((module) => module.events?.guildMessageCreate?.length ||
476
+ module.events?.messageDelete?.length)) {
417
477
  intents.push(GatewayIntentBits.GuildMessages);
418
478
  }
419
- if (modules.some((module) => module.events?.guildMemberAdd?.length)) {
479
+ if ((hasPrivilegedMessageCreate || requiresMessageContent) &&
480
+ messageContentEnabled) {
481
+ intents.push(GatewayIntentBits.MessageContent);
482
+ }
483
+ if (modules.some((module) => module.requiredDiscordCapabilities?.includes("guild-members") ||
484
+ module.events?.guildMemberAdd?.length ||
485
+ module.events?.guildMemberRemove?.length)) {
420
486
  intents.push(GatewayIntentBits.GuildMembers);
421
487
  }
422
488
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||