@helyx/bot 0.4.9 → 0.4.11

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/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
@@ -18,7 +18,7 @@ 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, DiscordResourceInspection, installModuleEvents, } from "./events.js";
22
22
  import { DiscordModuleLoggingService } from "./module-logging.js";
23
23
  import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
24
24
  import { startShardSupervisor } from "./shard-supervisor.js";
@@ -29,6 +29,7 @@ import { createSupportModule } from "./support-module.js";
29
29
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
30
30
  import { CoreResourceAuthorizationService } from "./resource-authorization.js";
31
31
  import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
32
+ import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
32
33
  export { loadConfiguredModulePackages } from "./modules.js";
33
34
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
34
35
  export * from "./control-server.js";
@@ -125,7 +126,7 @@ async function startUnshardedBot(processEnvironment) {
125
126
  }, "Module databases ready");
126
127
  const featureModules = modulePackages.map(({ definition }) => definition);
127
128
  const client = new Client({
128
- intents: gatewayIntentsForModules(featureModules),
129
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED),
129
130
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
130
131
  allowedMentions: { parse: [] },
131
132
  });
@@ -234,15 +235,25 @@ async function startUnshardedBot(processEnvironment) {
234
235
  ...publicModules,
235
236
  ...(operationsModule ? [operationsModule] : []),
236
237
  ];
237
- const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
238
+ const platformServices = new Map();
239
+ const serviceAccess = new ServiceRegistry(platformServices);
240
+ for (const [name, service] of createPlatformServices(database, publicModules, {
241
+ ...platformServiceOptions(environment, publicModules),
242
+ activationServices: serviceAccess,
243
+ })) {
244
+ platformServices.set(name, service);
245
+ }
246
+ const numericWriteBehind = createLocalNumericWriteBehind(platformServices, publicModules);
247
+ platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
238
248
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
249
+ platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
239
250
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
251
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
240
252
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
241
253
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
242
254
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
243
255
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
244
256
  }
245
- const serviceAccess = new ServiceRegistry(platformServices);
246
257
  const runtime = createRuntime({
247
258
  modules,
248
259
  logger: bootstrapLogger,
@@ -305,14 +316,29 @@ async function startUnshardedBot(processEnvironment) {
305
316
  process.exitCode = 1;
306
317
  bootstrapLogger.error({ event: "control.stop_failed", error: safeError(error) }, "Private bot control shutdown failed");
307
318
  });
308
- try {
309
- await moduleEvents.stop();
310
- await runtime.stop();
311
- }
312
- catch (error) {
319
+ await moduleEvents.stop().catch((error) => {
320
+ process.exitCode = 1;
321
+ bootstrapLogger.error({ event: "module_events.stop_failed", error: safeError(error) }, "Module event shutdown failed");
322
+ });
323
+ await runtime.stop().catch((error) => {
313
324
  process.exitCode = 1;
314
325
  bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
315
- }
326
+ });
327
+ await numericWriteBehind
328
+ .stop()
329
+ .then(({ drained }) => {
330
+ if (drained)
331
+ return;
332
+ process.exitCode = 1;
333
+ bootstrapLogger.error({ event: "numeric_write_behind.shutdown_not_drained" }, "Numeric write-behind exceeded its bounded shutdown drain");
334
+ })
335
+ .catch((error) => {
336
+ process.exitCode = 1;
337
+ bootstrapLogger.error({
338
+ event: "numeric_write_behind.shutdown_failed",
339
+ error: safeError(error),
340
+ }, "Numeric write-behind shutdown failed");
341
+ });
316
342
  await client.destroy();
317
343
  await closeDatabase();
318
344
  }
@@ -364,13 +390,26 @@ async function startUnshardedBot(processEnvironment) {
364
390
  await cloudConnector.stop().catch(() => undefined);
365
391
  if (controlServer)
366
392
  await controlServer.close().catch(() => undefined);
367
- try {
368
- await moduleEvents.stop();
369
- await runtime.stop();
370
- }
371
- catch (stopError) {
393
+ await moduleEvents.stop().catch((stopError) => {
394
+ bootstrapLogger.error({
395
+ event: "module_events.cleanup_failed",
396
+ error: safeError(stopError),
397
+ }, "Module event cleanup failed");
398
+ });
399
+ await runtime.stop().catch((stopError) => {
372
400
  bootstrapLogger.error({ event: "runtime.cleanup_failed", error: safeError(stopError) }, "Runtime cleanup failed");
373
- }
401
+ });
402
+ const numericCleanup = await numericWriteBehind
403
+ .stop()
404
+ .catch((stopError) => {
405
+ bootstrapLogger.error({
406
+ event: "numeric_write_behind.cleanup_failed",
407
+ error: safeError(stopError),
408
+ }, "Numeric write-behind cleanup failed");
409
+ return { drained: false };
410
+ });
411
+ if (!numericCleanup.drained)
412
+ bootstrapLogger.error({ event: "numeric_write_behind.cleanup_not_drained" }, "Numeric write-behind cleanup did not drain");
374
413
  await client.destroy();
375
414
  throw error;
376
415
  }
@@ -407,16 +446,27 @@ function deploymentReference(processEnvironment) {
407
446
  processEnvironment.GIT_COMMIT_SHA;
408
447
  return value?.trim().slice(0, 12) || undefined;
409
448
  }
410
- export function gatewayIntentsForModules(modules) {
449
+ export function gatewayIntentsForModules(modules, messageContentEnabled = false) {
411
450
  const intents = [GatewayIntentBits.Guilds];
412
451
  if (modules.some((module) => module.events?.messageReactionAdd?.length ||
413
452
  module.events?.messageReactionRemove?.length)) {
414
453
  intents.push(GatewayIntentBits.GuildMessageReactions);
415
454
  }
416
- if (modules.some((module) => module.events?.messageDelete?.length)) {
455
+ const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.length);
456
+ const requiresMessageContent = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
457
+ if (((hasPrivilegedMessageCreate || requiresMessageContent) &&
458
+ messageContentEnabled) ||
459
+ modules.some((module) => module.events?.guildMessageCreate?.length ||
460
+ module.events?.messageDelete?.length)) {
417
461
  intents.push(GatewayIntentBits.GuildMessages);
418
462
  }
419
- if (modules.some((module) => module.events?.guildMemberAdd?.length)) {
463
+ if ((hasPrivilegedMessageCreate || requiresMessageContent) &&
464
+ messageContentEnabled) {
465
+ intents.push(GatewayIntentBits.MessageContent);
466
+ }
467
+ if (modules.some((module) => module.requiredDiscordCapabilities?.includes("guild-members") ||
468
+ module.events?.guildMemberAdd?.length ||
469
+ module.events?.guildMemberRemove?.length)) {
420
470
  intents.push(GatewayIntentBits.GuildMembers);
421
471
  }
422
472
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
@@ -17,14 +17,18 @@ export function installInteractions(client, modules, registration, logger, servi
17
17
  if (guildCommands.length > 0 && !registration.internalGuildId) {
18
18
  throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
19
19
  }
20
- const buttons = uniqueMap(modules.flatMap((module) => (module.interactions?.buttons ?? []).map((contribution) => ({
20
+ const buttonItems = modules.flatMap((module) => (module.interactions?.buttons ?? []).map((contribution) => ({
21
21
  moduleId: module.manifest.id,
22
22
  contribution,
23
- }))), ({ contribution }) => contribution.customId, "button custom ID");
24
- const modals = uniqueMap(modules.flatMap((module) => (module.interactions?.modals ?? []).map((contribution) => ({
23
+ })));
24
+ const buttons = uniqueMap(buttonItems.filter(({ contribution }) => contribution.customId !== undefined), ({ contribution }) => contribution.customId, "button custom ID");
25
+ const buttonPrefixes = componentPrefixes(buttonItems, "button");
26
+ const modalItems = modules.flatMap((module) => (module.interactions?.modals ?? []).map((contribution) => ({
25
27
  moduleId: module.manifest.id,
26
28
  contribution,
27
- }))), ({ contribution }) => contribution.customId, "modal custom ID");
29
+ })));
30
+ const modals = uniqueMap(modalItems.filter(({ contribution }) => contribution.customId !== undefined), ({ contribution }) => contribution.customId, "modal custom ID");
31
+ const modalPrefixes = componentPrefixes(modalItems, "modal");
28
32
  const selects = uniqueMap(modules.flatMap((module) => (module.interactions?.selects ?? []).map((contribution) => ({
29
33
  moduleId: module.manifest.id,
30
34
  contribution,
@@ -124,7 +128,8 @@ export function installInteractions(client, modules, registration, logger, servi
124
128
  }
125
129
  }
126
130
  else if (interaction.isButton()) {
127
- const button = buttons.get(interaction.customId);
131
+ const button = buttons.get(interaction.customId) ??
132
+ resolveComponentPrefix(buttonPrefixes, interaction.customId);
128
133
  const context = button
129
134
  ? createContext(interaction, services)
130
135
  : undefined;
@@ -132,6 +137,11 @@ export function installInteractions(client, modules, registration, logger, servi
132
137
  button?.contribution.acknowledgement === "deferred-ephemeral") {
133
138
  await context.deferReply?.(true);
134
139
  }
140
+ if (button &&
141
+ interaction.isButton() &&
142
+ button.contribution.acknowledgement === "deferred-update") {
143
+ await interaction.deferUpdate();
144
+ }
135
145
  if (button &&
136
146
  context &&
137
147
  (await authorizeComponent(interaction, button.moduleId, services))) {
@@ -139,7 +149,8 @@ export function installInteractions(client, modules, registration, logger, servi
139
149
  }
140
150
  }
141
151
  else if (interaction.isModalSubmit()) {
142
- const modal = modals.get(interaction.customId);
152
+ const modal = modals.get(interaction.customId) ??
153
+ resolveComponentPrefix(modalPrefixes, interaction.customId);
143
154
  if (modal &&
144
155
  (await authorizeComponent(interaction, modal.moduleId, services))) {
145
156
  await modal.contribution.execute(createModalContext(interaction, services));
@@ -205,6 +216,9 @@ export function toCommandDefinition(registered) {
205
216
  if (!!command.execute === !!command.subcommands?.length) {
206
217
  throw new Error(`Command ${command.name} must define either execute or subcommands`);
207
218
  }
219
+ if (command.options?.length && command.subcommands?.length) {
220
+ throw new Error(`Command ${command.name} cannot define both direct options and subcommands`);
221
+ }
208
222
  const contextTypes = {
209
223
  guild: InteractionContextType.Guild,
210
224
  bot_dm: InteractionContextType.BotDM,
@@ -219,6 +233,9 @@ export function toCommandDefinition(registered) {
219
233
  .setDescription(command.description)
220
234
  .setContexts(command.contexts.map((context) => contextTypes[context]))
221
235
  .setIntegrationTypes(command.integrationTypes.map((integrationType) => integrationTypes[integrationType]));
236
+ for (const option of command.options ?? []) {
237
+ addCommandOption(builder, option);
238
+ }
222
239
  for (const subcommand of command.subcommands ?? []) {
223
240
  builder.addSubcommand((subcommandBuilder) => {
224
241
  subcommandBuilder
@@ -260,6 +277,13 @@ function addCommandOption(builder, option) {
260
277
  .setRequired(option.required ?? false));
261
278
  return;
262
279
  }
280
+ if (option.type === "user") {
281
+ builder.addUserOption((optionBuilder) => optionBuilder
282
+ .setName(option.name)
283
+ .setDescription(option.description)
284
+ .setRequired(option.required ?? false));
285
+ return;
286
+ }
263
287
  if (option.type === "role") {
264
288
  builder.addRoleOption((optionBuilder) => optionBuilder
265
289
  .setName(option.name)
@@ -291,9 +315,43 @@ export function uniqueMap(items, keyOf, label) {
291
315
  }
292
316
  return result;
293
317
  }
318
+ function componentPrefixes(items, label) {
319
+ const prefixes = [];
320
+ for (const item of items) {
321
+ const { customId, customIdPrefix } = item.contribution;
322
+ if ((customId === undefined) === (customIdPrefix === undefined)) {
323
+ throw new Error(`${label} contribution must declare exactly one custom ID or prefix`);
324
+ }
325
+ const route = customId ?? customIdPrefix;
326
+ if (route.length < 1 ||
327
+ route.length > 100 ||
328
+ !/^[A-Za-z0-9._:-]+$/u.test(route)) {
329
+ throw new Error(`${label} custom ID routing value is invalid`);
330
+ }
331
+ if (customIdPrefix !== undefined)
332
+ prefixes.push(item);
333
+ }
334
+ for (let index = 0; index < prefixes.length; index += 1) {
335
+ const prefix = prefixes[index].contribution.customIdPrefix;
336
+ if (prefixes.some((candidate, candidateIndex) => candidateIndex !== index &&
337
+ (candidate.contribution.customIdPrefix ?? "").startsWith(prefix))) {
338
+ throw new Error(`Ambiguous ${label} custom ID prefix: ${prefix}`);
339
+ }
340
+ }
341
+ return prefixes;
342
+ }
343
+ function resolveComponentPrefix(items, customId) {
344
+ if (customId.length < 1 ||
345
+ customId.length > 100 ||
346
+ !/^[A-Za-z0-9._:-]+$/u.test(customId)) {
347
+ return undefined;
348
+ }
349
+ return items.find(({ contribution }) => customId.startsWith(contribution.customIdPrefix ?? ""));
350
+ }
294
351
  function createContext(interaction, services) {
295
352
  return {
296
353
  interactionId: interaction.id,
354
+ ...("customId" in interaction ? { customId: interaction.customId } : {}),
297
355
  userId: interaction.user.id,
298
356
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
299
357
  userRoleIds: interaction.member instanceof GuildMember
@@ -322,6 +380,9 @@ function createContext(interaction, services) {
322
380
  ...(interaction.isMessageComponent()
323
381
  ? {
324
382
  updateResponse: async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined),
383
+ editSourceMessage: async (response) => interaction.webhook
384
+ .editMessage(interaction.message.id, toUpdateOptions(response))
385
+ .then(() => undefined),
325
386
  }
326
387
  : {}),
327
388
  showModal: async (modal) => interaction.showModal(toModal(modal)).then(() => undefined),
@@ -336,6 +397,16 @@ function createCommandContext(interaction, services) {
336
397
  ...createContext(interaction, services),
337
398
  getStringOption: (name) => interaction.options.getString(name),
338
399
  getBooleanOption: (name) => interaction.options.getBoolean(name),
400
+ getUserOption: (name) => {
401
+ const user = interaction.options.getUser(name);
402
+ return user
403
+ ? {
404
+ id: user.id,
405
+ displayName: user.globalName ?? user.username,
406
+ isBot: user.bot,
407
+ }
408
+ : null;
409
+ },
339
410
  getRoleOption: (name) => {
340
411
  const role = interaction.options.getRole(name);
341
412
  return role ? { id: role.id, name: role.name } : null;
@@ -397,6 +468,7 @@ function createModalContext(interaction, services) {
397
468
  : undefined;
398
469
  return {
399
470
  interactionId: interaction.id,
471
+ customId: interaction.customId,
400
472
  userId: interaction.user.id,
401
473
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
402
474
  userRoleIds: interaction.member instanceof GuildMember
@@ -701,19 +773,10 @@ function toSelectRows(response) {
701
773
  return rows;
702
774
  }
703
775
  export function toUpdateOptions(response) {
704
- const options = toReplyOptions({
776
+ return toReplyOptions({
705
777
  ...response,
706
778
  ephemeral: false,
707
779
  });
708
- return response.componentsV2
709
- ? {
710
- ...options,
711
- // Discord requires legacy fields to be cleared when an existing
712
- // message is first converted to Components V2.
713
- content: null,
714
- embeds: [],
715
- }
716
- : options;
717
780
  }
718
781
  function toAllowedUserMentions(userIds) {
719
782
  if (userIds === undefined)
@@ -18,6 +18,7 @@ const VALUE_MODAL_ID = "helyx.management.value.modal";
18
18
  const VALUE_FIELD_ID = "value";
19
19
  const SESSION_PURPOSE = "modules.manage";
20
20
  const PAGE_SIZE = 25;
21
+ const MAX_DISCORD_SETTING_SELECTIONS = 25;
21
22
  const SESSION_LIFETIME_MS = 15 * 60 * 1_000;
22
23
  const VIOLET = 0x7c3aed;
23
24
  const SUCCESS = 0x16a34a;
@@ -177,7 +178,14 @@ export function createManagementModule(configurableModules, resources) {
177
178
  return;
178
179
  }
179
180
  try {
180
- module.configuration?.validate(value);
181
+ const validated = module.configuration?.validate(value);
182
+ if (validated && module.configuration?.validateUpdate) {
183
+ await module.configuration.validateUpdate({
184
+ guildId: context.serverId,
185
+ value: validated,
186
+ services: context.services,
187
+ });
188
+ }
181
189
  }
182
190
  catch (error) {
183
191
  await context.reply(validationResponse(error instanceof Error
@@ -563,8 +571,12 @@ function settingValueResponse(setting) {
563
571
  {
564
572
  type: "discord-channel",
565
573
  customId: VALUE_SELECT_ID,
566
- placeholder: "Choose a channel",
574
+ placeholder: setting.multiple
575
+ ? "Choose channels"
576
+ : "Choose a channel",
567
577
  channelTypes: setting.channelTypes,
578
+ minValues: 1,
579
+ maxValues: setting.multiple ? MAX_DISCORD_SETTING_SELECTIONS : 1,
568
580
  },
569
581
  ],
570
582
  };
@@ -631,6 +643,8 @@ function initialDraft(module, existing) {
631
643
  return result;
632
644
  }
633
645
  function emptyValue(setting) {
646
+ if (setting.type === "discord-channel" && setting.multiple)
647
+ return [];
634
648
  if (setting.type === "discord-role" && setting.multiple)
635
649
  return [];
636
650
  if (setting.type === "string" ||
@@ -655,10 +669,13 @@ function selectedValue(setting, values) {
655
669
  return values[0];
656
670
  }
657
671
  if (setting.type === "discord-channel") {
658
- if (values.length !== 1 || !isSnowflake(values[0])) {
659
- throw new Error("Selected channel is invalid");
672
+ if (values.length < 1 ||
673
+ values.length > (setting.multiple ? MAX_DISCORD_SETTING_SELECTIONS : 1) ||
674
+ new Set(values).size !== values.length ||
675
+ values.some((value) => !isSnowflake(value))) {
676
+ throw new Error("Selected channel value is invalid");
660
677
  }
661
- return values[0];
678
+ return setting.multiple ? [...values] : values[0];
662
679
  }
663
680
  if (setting.type === "discord-role") {
664
681
  if (values.length < 1 ||
@@ -723,9 +740,21 @@ async function validateDiscordSettings(directory, guildId, module, values) {
723
740
  if (configured === "" || configured === undefined)
724
741
  continue;
725
742
  if (setting.type === "discord-channel") {
726
- const channel = typeof configured === "string" ? channels.get(configured) : undefined;
727
- if (!channel || !setting.channelTypes.includes(channel.type)) {
728
- return `${setting.label} must be an available supported Discord channel.`;
743
+ if (setting.multiple !== Array.isArray(configured)) {
744
+ return `${setting.label} has an invalid Discord channel selection.`;
745
+ }
746
+ const configuredChannels = Array.isArray(configured)
747
+ ? configured
748
+ : [configured];
749
+ if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
750
+ new Set(configuredChannels).size !== configuredChannels.length ||
751
+ configuredChannels.some((channelId) => {
752
+ if (typeof channelId !== "string")
753
+ return true;
754
+ const channel = channels.get(channelId);
755
+ return !channel || !setting.channelTypes.includes(channel.type);
756
+ })) {
757
+ return `${setting.label} must contain available supported Discord channels.`;
729
758
  }
730
759
  }
731
760
  if (setting.type === "discord-role") {
@@ -776,8 +805,11 @@ function settingStatus(setting, value) {
776
805
  }
777
806
  if (setting.type === "boolean")
778
807
  return value === true ? "Enabled" : "Disabled";
779
- if (setting.type === "discord-channel")
780
- return "Channel selected";
808
+ if (setting.type === "discord-channel") {
809
+ return Array.isArray(value)
810
+ ? `${String(value.length)} channels selected`
811
+ : "Channel selected";
812
+ }
781
813
  if (setting.type === "discord-role") {
782
814
  return Array.isArray(value)
783
815
  ? `${String(value.length)} roles selected`
@@ -0,0 +1,5 @@
1
+ import { type Message } from "discord.js";
2
+ import type { GuildMessageClassification } from "@helyx/sdk";
3
+ export declare function classifyGuildMessage(message: Pick<Message, "attachments" | "content" | "embeds" | "flags" | "stickers">): GuildMessageClassification;
4
+ export declare const unavailableGuildMessageClassification: GuildMessageClassification;
5
+ //# sourceMappingURL=message-classification.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { MessageFlags } from "discord.js";
2
+ const LINK_PATTERN = /https?:\/\/[^\s<>]+/giu;
3
+ const GIF_LINK_PATTERN = /(?:https?:\/\/[^\s<>]*(?:\.gif(?:[?#][^\s<>]*)?|giphy\.com|tenor\.com|media\.discordapp\.net[^\s<>]*\.gif))/giu;
4
+ const CUSTOM_EMOJI_PATTERN = /<a?:[a-zA-Z0-9_]{2,32}:\d{17,20}>/gu;
5
+ const UNICODE_EMOJI_PATTERN = /(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\p{Emoji_Modifier})?)*)/gu;
6
+ export function classifyGuildMessage(message) {
7
+ const kinds = new Set();
8
+ const content = message.content.trim();
9
+ const withoutLinks = content.replace(LINK_PATTERN, " ").trim();
10
+ const withoutEmoji = withoutLinks
11
+ .replace(CUSTOM_EMOJI_PATTERN, " ")
12
+ .replace(UNICODE_EMOJI_PATTERN, " ")
13
+ .replace(/\u200D|\uFE0F|\p{Emoji_Modifier}|\s/gu, "");
14
+ if (matches(LINK_PATTERN, content))
15
+ kinds.add("link");
16
+ if (withoutLinks.length > 0 &&
17
+ withoutEmoji.length === 0 &&
18
+ (matches(CUSTOM_EMOJI_PATTERN, withoutLinks) ||
19
+ matches(UNICODE_EMOJI_PATTERN, withoutLinks)))
20
+ kinds.add("emoji");
21
+ else if (withoutEmoji.length > 0)
22
+ kinds.add("text");
23
+ const voiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
24
+ if (voiceMessage)
25
+ kinds.add("voice-message");
26
+ for (const attachment of message.attachments.values()) {
27
+ const contentType = attachment.contentType?.toLowerCase() ?? "";
28
+ const filename = attachment.name.toLowerCase();
29
+ if (voiceMessage)
30
+ continue;
31
+ if (contentType === "image/gif" || filename.endsWith(".gif"))
32
+ kinds.add("gif");
33
+ else if (contentType.startsWith("image/"))
34
+ kinds.add("image");
35
+ else
36
+ kinds.add("file");
37
+ }
38
+ for (const embed of message.embeds) {
39
+ const urls = [embed.url, embed.image?.url, embed.thumbnail?.url]
40
+ .filter((value) => Boolean(value))
41
+ .join(" ");
42
+ if (matches(GIF_LINK_PATTERN, urls))
43
+ kinds.add("gif");
44
+ else if (embed.image || embed.thumbnail)
45
+ kinds.add("image");
46
+ }
47
+ if (matches(GIF_LINK_PATTERN, content))
48
+ kinds.add("gif");
49
+ if (message.stickers.size > 0)
50
+ kinds.add("sticker");
51
+ return {
52
+ available: true,
53
+ kinds: [
54
+ "text",
55
+ "emoji",
56
+ "link",
57
+ "image",
58
+ "gif",
59
+ "file",
60
+ "sticker",
61
+ "voice-message",
62
+ ].filter((kind) => kinds.has(kind)),
63
+ };
64
+ }
65
+ export const unavailableGuildMessageClassification = {
66
+ available: false,
67
+ kinds: [],
68
+ };
69
+ function matches(pattern, value) {
70
+ pattern.lastIndex = 0;
71
+ const result = pattern.test(value);
72
+ pattern.lastIndex = 0;
73
+ return result;
74
+ }
75
+ //# sourceMappingURL=message-classification.js.map
package/dist/modules.js CHANGED
@@ -90,12 +90,21 @@ async function loadDefinitions(specifiers, importer) {
90
90
  return orderedManifests.map((manifest) => byId.get(manifest.id));
91
91
  }
92
92
  export function validateContributionParity(definition) {
93
+ const requiredDiscordCapabilities = definition.requiredDiscordCapabilities ?? [];
94
+ if (new Set(requiredDiscordCapabilities).size !==
95
+ requiredDiscordCapabilities.length ||
96
+ requiredDiscordCapabilities.some((capability) => capability !== "guild-members" && capability !== "message-content")) {
97
+ throw new Error("Module requests an unsupported or duplicate Discord capability");
98
+ }
93
99
  const executableCommands = (definition.interactions?.commands ?? []).map(({ name }) => name);
94
100
  const executableEvents = [
101
+ ...(definition.events?.privilegedMessageCreate ?? []),
102
+ ...(definition.events?.guildMessageCreate ?? []),
95
103
  ...(definition.events?.messageReactionAdd ?? []),
96
104
  ...(definition.events?.messageReactionRemove ?? []),
97
105
  ...(definition.events?.messageDelete ?? []),
98
106
  ...(definition.events?.guildMemberAdd ?? []),
107
+ ...(definition.events?.guildMemberRemove ?? []),
99
108
  ...(definition.events?.guildRoleDelete ?? []),
100
109
  ...(definition.events?.guildRoleUpdate ?? []),
101
110
  ...(definition.events?.channelDelete ?? []),
@@ -0,0 +1,62 @@
1
+ import { type NumericWriteBehindCommittedBatch, type NumericWriteBehindEnqueueInput, type NumericWriteBehindMemberScope, type NumericWriteBehindScope, type NumericWriteBehindService } from "@helyx/sdk";
2
+ import { type ShardIdentity } from "./shard-contracts.js";
3
+ export declare class WorkerNumericWriteBehindProxy implements NumericWriteBehindService {
4
+ #private;
5
+ constructor(identity: ShardIdentity);
6
+ enqueue(input: NumericWriteBehindEnqueueInput): Promise<{
7
+ outcome: "accepted" | "cooldown" | "suppressed" | "paused" | "capacity" | "unavailable";
8
+ }>;
9
+ pendingMemberSnapshot(input: NumericWriteBehindMemberScope): Promise<{
10
+ increments: Readonly<Record<string, number>>;
11
+ maxima: Readonly<Record<string, Date>>;
12
+ oldestAcceptedAt: Date | null;
13
+ }>;
14
+ flushMember(input: NumericWriteBehindMemberScope): Promise<void>;
15
+ flushGuild(input: NumericWriteBehindScope & {
16
+ guildId: string;
17
+ }): Promise<void>;
18
+ flushSubject(input: NumericWriteBehindScope & {
19
+ subjectId: string;
20
+ }): Promise<void>;
21
+ flushModule(input: NumericWriteBehindScope): Promise<void>;
22
+ runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: () => Promise<Result>): Promise<Result>;
23
+ runGuildBarrier<Result>(input: NumericWriteBehindScope & {
24
+ guildId: string;
25
+ }, operation: () => Promise<Result>): Promise<Result>;
26
+ runSubjectBarrier<Result>(input: NumericWriteBehindScope & {
27
+ subjectId: string;
28
+ }, operation: () => Promise<Result>): Promise<Result>;
29
+ suppressSubject(input: NumericWriteBehindScope & {
30
+ subjectId: string;
31
+ cutoff: Date;
32
+ }): Promise<{
33
+ suppressionId: string;
34
+ discarded: number;
35
+ }>;
36
+ suppressGuild(input: NumericWriteBehindScope & {
37
+ guildId: string;
38
+ }): Promise<{
39
+ suppressionId: string;
40
+ discarded: number;
41
+ }>;
42
+ releaseGuild(input: NumericWriteBehindScope & {
43
+ guildId: string;
44
+ suppressionId: string;
45
+ }): Promise<{
46
+ released: boolean;
47
+ }>;
48
+ releaseSubject(input: NumericWriteBehindScope & {
49
+ subjectId: string;
50
+ suppressionId: string;
51
+ }): Promise<{
52
+ released: boolean;
53
+ }>;
54
+ clearMemberCooldown(input: NumericWriteBehindMemberScope): Promise<void>;
55
+ clearSubjectCooldown(input: NumericWriteBehindScope & {
56
+ subjectId: string;
57
+ }): Promise<void>;
58
+ subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch) => void | Promise<void>): () => void;
59
+ stop(): void;
60
+ }
61
+ export declare function encodeNumericIpcValue(input: unknown): unknown;
62
+ //# sourceMappingURL=numeric-write-behind-proxy.d.ts.map