@helyx/bot 0.4.11 → 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.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 { DiscordMessageDeliveryService, DiscordMemberEnforcementService, 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";
@@ -87,15 +88,17 @@ async function startUnshardedBot(processEnvironment) {
87
88
  verifiedMigrationCount: migrationResult.verified.length,
88
89
  }, "Core database ready");
89
90
  const cleanupExpiredCoreRecords = async () => {
90
- const [sessions, rateLimits] = await Promise.all([
91
+ const [sessions, rateLimits, verificationAudit] = await Promise.all([
91
92
  new InteractionSessionRepository(database).deleteExpired(),
92
93
  new RateLimitRepository(database).deleteExpired(),
94
+ new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
93
95
  ]);
94
- if (sessions > 0 || rateLimits > 0) {
96
+ if (sessions > 0 || rateLimits > 0 || verificationAudit > 0) {
95
97
  bootstrapLogger.info({
96
98
  event: "database.retention_cleaned",
97
99
  interactionSessions: sessions,
98
100
  rateLimitWindows: rateLimits,
101
+ verificationAuditEvents: verificationAudit,
99
102
  }, "Expired core records removed");
100
103
  }
101
104
  };
@@ -243,6 +246,18 @@ async function startUnshardedBot(processEnvironment) {
243
246
  })) {
244
247
  platformServices.set(name, service);
245
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
+ }
246
261
  const numericWriteBehind = createLocalNumericWriteBehind(platformServices, publicModules);
247
262
  platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
248
263
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
@@ -266,7 +281,7 @@ async function startUnshardedBot(processEnvironment) {
266
281
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
267
282
  ...(operationsModule ? [operationsModule.manifest.id] : []),
268
283
  ]);
269
- 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);
270
285
  const commandsReady = installInteractions(client, modules, {
271
286
  ...(environment.DISCORD_INTERNAL_GUILD_ID
272
287
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -298,6 +313,7 @@ async function startUnshardedBot(processEnvironment) {
298
313
  if (shuttingDown)
299
314
  return;
300
315
  shuttingDown = true;
316
+ prepareGuilds.stop();
301
317
  process.off("SIGINT", onSigint);
302
318
  process.off("SIGTERM", onSigterm);
303
319
  if (retentionCleanupTimer)
@@ -294,6 +294,7 @@ function addCommandOption(builder, option) {
294
294
  const channelTypes = {
295
295
  guild_text: ChannelType.GuildText,
296
296
  guild_announcement: ChannelType.GuildAnnouncement,
297
+ guild_category: ChannelType.GuildCategory,
297
298
  };
298
299
  builder.addChannelOption((optionBuilder) => {
299
300
  optionBuilder
@@ -376,6 +377,7 @@ function createContext(interaction, services) {
376
377
  ...(ephemeral ? { flags: MessageFlags.Ephemeral } : {}),
377
378
  });
378
379
  },
380
+ deleteReplyAfter: (milliseconds) => scheduleReplyDeletion(interaction, milliseconds),
379
381
  reply: (response) => replyToInteraction(interaction, response),
380
382
  ...(interaction.isMessageComponent()
381
383
  ? {
@@ -490,6 +492,7 @@ function createModalContext(interaction, services) {
490
492
  ...(ephemeral ? { flags: MessageFlags.Ephemeral } : {}),
491
493
  });
492
494
  },
495
+ deleteReplyAfter: (milliseconds) => scheduleReplyDeletion(interaction, milliseconds),
493
496
  reply: (response) => replyToInteraction(interaction, response),
494
497
  ...(updateResponse ? { updateResponse } : {}),
495
498
  getField: (customId) => interaction.fields.getTextInputValue(customId),
@@ -517,6 +520,16 @@ function createModalContext(interaction, services) {
517
520
  addChannelMessageReaction: (channelId, messageId, emojiIdentifier) => addChannelMessageReaction(interaction.client, channelId, messageId, emojiIdentifier),
518
521
  };
519
522
  }
523
+ function scheduleReplyDeletion(interaction, milliseconds) {
524
+ if (!Number.isInteger(milliseconds) ||
525
+ milliseconds < 1_000 ||
526
+ milliseconds > 900_000)
527
+ throw new Error("Reply deletion delay must be between 1 second and 15 minutes");
528
+ const timer = setTimeout(() => {
529
+ void interaction.deleteReply().catch(() => undefined);
530
+ }, milliseconds);
531
+ timer.unref();
532
+ }
520
533
  function createSelectContext(interaction, services) {
521
534
  return {
522
535
  ...createContext(interaction, services),
@@ -739,9 +752,7 @@ function toSelectRows(response) {
739
752
  .setPlaceholder(select.placeholder)
740
753
  .setMinValues(select.minValues ?? 1)
741
754
  .setMaxValues(select.maxValues ?? 1)
742
- .setChannelTypes(...select.channelTypes.map((type) => type === "guild_announcement"
743
- ? ChannelType.GuildAnnouncement
744
- : ChannelType.GuildText))));
755
+ .setChannelTypes(...select.channelTypes.map(discordChannelType))));
745
756
  continue;
746
757
  }
747
758
  if (select.type === "discord-role") {
@@ -881,9 +892,7 @@ export function toModal(definition) {
881
892
  .setRequired(field.required ?? false)
882
893
  .setMinValues(field.minValues ?? (field.required ? 1 : 0))
883
894
  .setMaxValues(field.maxValues ?? 1)
884
- .setChannelTypes(...field.channelTypes.map((type) => type === "guild_announcement"
885
- ? ChannelType.GuildAnnouncement
886
- : ChannelType.GuildText));
895
+ .setChannelTypes(...field.channelTypes.map(discordChannelType));
887
896
  if (field.placeholder !== undefined)
888
897
  input.setPlaceholder(field.placeholder);
889
898
  if (field.defaultValues?.length)
@@ -894,6 +903,13 @@ export function toModal(definition) {
894
903
  }
895
904
  return modal;
896
905
  }
906
+ function discordChannelType(type) {
907
+ if (type === "guild_announcement")
908
+ return ChannelType.GuildAnnouncement;
909
+ if (type === "guild_category")
910
+ return ChannelType.GuildCategory;
911
+ return ChannelType.GuildText;
912
+ }
897
913
  async function sendFailure(interaction) {
898
914
  if (interaction.isAutocomplete()) {
899
915
  await interaction.respond([]).catch(() => undefined);
@@ -2,7 +2,7 @@ import { type ModuleDefinition } from "@helyx/sdk";
2
2
  export interface ManagementGuildResources {
3
3
  channels: readonly {
4
4
  id: string;
5
- type: "guild_text" | "guild_announcement";
5
+ type: "guild_text" | "guild_announcement" | "guild_category";
6
6
  }[];
7
7
  roles: readonly {
8
8
  id: string;