@helyx/bot 0.4.46 → 0.5.0

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.
@@ -0,0 +1,24 @@
1
+ import type { DiscordInviteInspectionService, DiscordInviteSnapshot, DiscordInviteUnavailable } from "@helyx/sdk";
2
+ import { type Client } from "discord.js";
3
+ export declare class DiscordInviteInspection implements DiscordInviteInspectionService {
4
+ #private;
5
+ private readonly client;
6
+ constructor(client: Client, fingerprintKey: Uint8Array);
7
+ continuityGeneration(input: {
8
+ guildId: string;
9
+ }): string;
10
+ invalidateContinuity(input: {
11
+ guildId: string;
12
+ reason: "event_queue_gap" | "gateway_session_gap" | "ownership_gap";
13
+ }): void;
14
+ fingerprintCode(input: {
15
+ guildId: string;
16
+ code: string;
17
+ }): Promise<{
18
+ sourceKey: string;
19
+ }>;
20
+ captureSnapshot(input: {
21
+ guildId: string;
22
+ }): Promise<DiscordInviteSnapshot | DiscordInviteUnavailable>;
23
+ }
24
+ //# sourceMappingURL=discord-invites.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { PermissionFlagsBits } from "discord.js";
2
+ import { createHmac, randomUUID } from "node:crypto";
3
+ const VANITY_FEATURE = "VANITY_URL";
4
+ export class DiscordInviteInspection {
5
+ client;
6
+ #processGeneration = randomUUID();
7
+ #guildGenerations = new Map();
8
+ #fingerprintKey;
9
+ constructor(client, fingerprintKey) {
10
+ this.client = client;
11
+ if (fingerprintKey.byteLength !== 32)
12
+ throw new Error("Discord invite fingerprint key must contain 32 bytes");
13
+ this.#fingerprintKey = Buffer.from(fingerprintKey);
14
+ }
15
+ continuityGeneration(input) {
16
+ const readyGeneration = this.client.readyTimestamp ?? 0;
17
+ const localGeneration = this.#guildGenerations.get(input.guildId) ?? 0;
18
+ return `${this.#processGeneration}:${readyGeneration}:${localGeneration}`;
19
+ }
20
+ invalidateContinuity(input) {
21
+ void input.reason;
22
+ this.#guildGenerations.set(input.guildId, (this.#guildGenerations.get(input.guildId) ?? 0) + 1);
23
+ }
24
+ fingerprintCode(input) {
25
+ const code = input.code.trim();
26
+ if (!/^\d{17,20}$/u.test(input.guildId))
27
+ return Promise.reject(new Error("Discord server ID is invalid"));
28
+ if (code.length < 1 || code.length > 128)
29
+ return Promise.reject(new Error("Discord invite code is invalid"));
30
+ return Promise.resolve({
31
+ sourceKey: this.#fingerprint(input.guildId, code),
32
+ });
33
+ }
34
+ async captureSnapshot(input) {
35
+ const guild = this.client.guilds.cache.get(input.guildId);
36
+ if (!guild)
37
+ return unavailable("discord_unavailable");
38
+ if (!hasManageGuild(guild))
39
+ return unavailable("missing_permission");
40
+ try {
41
+ const [invites, vanity] = await Promise.all([
42
+ guild.invites.fetch(),
43
+ guild.features.includes(VANITY_FEATURE)
44
+ ? guild.fetchVanityData().catch(() => null)
45
+ : Promise.resolve(null),
46
+ ]);
47
+ const observedAt = new Date();
48
+ return {
49
+ available: true,
50
+ observedAt,
51
+ continuityGeneration: this.continuityGeneration(input),
52
+ invites: [...invites.values()]
53
+ .map((invite) => ({
54
+ sourceKey: this.#fingerprint(guild.id, invite.code),
55
+ channelId: invite.channelId,
56
+ inviterUserId: invite.inviterId,
57
+ createdAt: invite.createdAt,
58
+ expiresAt: invite.expiresAt,
59
+ maxUses: invite.maxUses ?? 0,
60
+ uses: invite.uses ?? 0,
61
+ temporary: invite.temporary ?? false,
62
+ }))
63
+ .sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)),
64
+ vanity: vanity?.code && typeof vanity.uses === "number"
65
+ ? {
66
+ sourceKey: this.#fingerprint(guild.id, vanity.code),
67
+ uses: vanity.uses,
68
+ }
69
+ : null,
70
+ };
71
+ }
72
+ catch {
73
+ return unavailable("discord_unavailable");
74
+ }
75
+ }
76
+ #fingerprint(guildId, code) {
77
+ return createHmac("sha256", this.#fingerprintKey)
78
+ .update("helyx.discord-invite-source.v1\0", "utf8")
79
+ .update(guildId, "utf8")
80
+ .update("\0", "utf8")
81
+ .update(code, "utf8")
82
+ .digest("base64url");
83
+ }
84
+ }
85
+ function hasManageGuild(guild) {
86
+ return (guild.members.me?.permissions.has(PermissionFlagsBits.ManageGuild) ?? false);
87
+ }
88
+ function unavailable(code) {
89
+ return { available: false, code };
90
+ }
91
+ //# sourceMappingURL=discord-invites.js.map
package/dist/events.js CHANGED
@@ -122,6 +122,28 @@ export function installModuleEvents(client, modules, logger, services) {
122
122
  const guildMessageQueue = guildMessageContributions.length > 0
123
123
  ? new BoundedGuildEventQueue(logger)
124
124
  : null;
125
+ const inviteInspection = memberAddContributions.some(({ moduleId }) => moduleId === "helyx.invite-tracking") &&
126
+ typeof services.has === "function" &&
127
+ services.has(HELYX_SERVICE_NAMES.discordInvites)
128
+ ? services.get(HELYX_SERVICE_NAMES.discordInvites)
129
+ : null;
130
+ if (inviteInspection) {
131
+ client.on(Events.ShardDisconnect, (_event, shardId) => {
132
+ for (const guild of client.guilds.cache.values())
133
+ if (guild.shardId === shardId)
134
+ inviteInspection.invalidateContinuity({
135
+ guildId: guild.id,
136
+ reason: "gateway_session_gap",
137
+ });
138
+ });
139
+ client.on(Events.Invalidated, () => {
140
+ for (const guildId of client.guilds.cache.keys())
141
+ inviteInspection.invalidateContinuity({
142
+ guildId,
143
+ reason: "gateway_session_gap",
144
+ });
145
+ });
146
+ }
125
147
  if (privilegedMessageContributions.length > 0) {
126
148
  client.on(Events.MessageCreate, (message) => {
127
149
  if (!message.guildId || !privilegedEligibilityQueue)
@@ -231,7 +253,12 @@ export function installModuleEvents(client, modules, logger, services) {
231
253
  }
232
254
  if (memberAddContributions.length > 0) {
233
255
  client.on(Events.GuildMemberAdd, (member) => {
234
- queue.enqueue(member.guild.id, () => handleMemberAdd(member));
256
+ const accepted = queue.enqueue(member.guild.id, () => handleMemberAdd(member));
257
+ if (!accepted && inviteInspection)
258
+ inviteInspection.invalidateContinuity({
259
+ guildId: member.guild.id,
260
+ reason: "event_queue_gap",
261
+ });
235
262
  });
236
263
  }
237
264
  if (memberRemoveContributions.length > 0) {
@@ -487,9 +514,26 @@ export function installModuleEvents(client, modules, logger, services) {
487
514
  if (!member.joinedAt)
488
515
  throw new Error("Discord member join timestamp is unavailable");
489
516
  const installations = availabilityAwareInstallations(services);
517
+ const continuityGeneration = inviteInspection
518
+ ? inviteInspection.continuityGeneration({ guildId: member.guild.id })
519
+ : `runtime:${client.readyTimestamp ?? 0}`;
520
+ const occurrenceId = createHash("sha256")
521
+ .update("helyx.guild-member-add.v1\0", "utf8")
522
+ .update(member.guild.id, "utf8")
523
+ .update("\0", "utf8")
524
+ .update(member.id, "utf8")
525
+ .update("\0", "utf8")
526
+ .update(member.joinedAt.toISOString(), "utf8")
527
+ .digest("base64url");
490
528
  for (const { moduleId, contribution } of memberAddContributions) {
491
529
  try {
492
530
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
531
+ if (moduleId === "helyx.invite-tracking" && inviteInspection) {
532
+ inviteInspection.invalidateContinuity({
533
+ guildId: member.guild.id,
534
+ reason: "ownership_gap",
535
+ });
536
+ }
493
537
  continue;
494
538
  }
495
539
  await contribution.execute({
@@ -500,6 +544,8 @@ export function installModuleEvents(client, modules, logger, services) {
500
544
  memberIsBot: member.user.bot,
501
545
  accountCreatedAt: member.user.createdAt,
502
546
  joinedAt: member.joinedAt,
547
+ occurrenceId,
548
+ continuityGeneration,
503
549
  services,
504
550
  assignRole: (roleId) => assignMemberRole(member, roleId),
505
551
  sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
@@ -822,10 +868,11 @@ class BoundedGuildEventQueue {
822
868
  enqueue(guildId, run) {
823
869
  if (!this.#accepting || this.#pending.length >= 1_000) {
824
870
  this.logger.warn({ event: "module.event_dropped", reason: "queue_capacity" }, "Module gateway event queue rejected work");
825
- return;
871
+ return false;
826
872
  }
827
873
  this.#pending.push({ guildId, run });
828
874
  this.#pump();
875
+ return true;
829
876
  }
830
877
  queueDepth() {
831
878
  return this.#pending.length;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { StatusHeartbeatReporter } from "@helyx/status-client";
3
3
  import { safeError, ServiceRegistry } from "@helyx/core";
4
4
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
5
5
  import { createRuntime } from "@helyx/framework";
6
- import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
6
+ import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
7
7
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
8
8
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
9
9
  import pino from "pino";
@@ -33,6 +33,8 @@ import { createPrivacyModule } from "./privacy-module.js";
33
33
  import { HttpOAuthService } from "./oauth-service.js";
34
34
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
35
35
  import { DiscordGuildHealthInspection } from "./discord-guild-health.js";
36
+ import { DiscordAnalyticsInspection } from "./discord-analytics.js";
37
+ import { DiscordInviteInspection } from "./discord-invites.js";
36
38
  import { GuildCommandWorker } from "./guild-command-worker.js";
37
39
  import { GuildCommandDiscordClient } from "./guild-command-discord-client.js";
38
40
  import { GuildCommandReadService } from "./guild-command-read-service.js";
@@ -320,8 +322,9 @@ async function startUnshardedBot(processEnvironment) {
320
322
  ];
321
323
  const platformServices = new Map();
322
324
  const serviceAccess = new ServiceRegistry(platformServices);
325
+ const serviceOptions = platformServiceOptions(environment, publicModules);
323
326
  for (const [name, service] of createPlatformServices(database, publicModules, {
324
- ...platformServiceOptions(environment, publicModules),
327
+ ...serviceOptions,
325
328
  activationServices: serviceAccess,
326
329
  })) {
327
330
  platformServices.set(name, service);
@@ -349,9 +352,14 @@ async function startUnshardedBot(processEnvironment) {
349
352
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
350
353
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
351
354
  platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
352
- platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
355
+ const memberRoles = new DiscordMemberRoleService(client);
356
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
357
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
353
358
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
354
359
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
360
+ platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
361
+ if (serviceOptions.inviteFingerprintKey)
362
+ platformServices.set(HELYX_SERVICE_NAMES.discordInvites, new DiscordInviteInspection(client, serviceOptions.inviteFingerprintKey));
355
363
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
356
364
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
357
365
  }
@@ -4,6 +4,7 @@ import { canUseCommand } from "@helyx/core";
4
4
  import { commandRegistrationPolicy, contributionRegistrationPolicy, } from "./command-registration-policy.js";
5
5
  import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelSelectMenuBuilder, EmbedBuilder, Events, GuildMember, ApplicationIntegrationType, ChannelType, InteractionContextType, LabelBuilder, MessageFlags, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, Routes, StringSelectMenuBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, TextDisplayBuilder, PermissionFlagsBits, escapeMarkdown, } from "discord.js";
6
6
  import { collectCommandRegistrations, } from "./shared-commands.js";
7
+ import { resolveDirectCommandPath } from "./command-path-contract.js";
7
8
  const buttonStyles = {
8
9
  primary: ButtonStyle.Primary,
9
10
  secondary: ButtonStyle.Secondary,
@@ -108,10 +109,7 @@ export function installInteractions(client, modules, registration, logger, servi
108
109
  if (!registered ||
109
110
  !subcommand?.autocomplete ||
110
111
  !ownerModuleId ||
111
- !(await authorizeAutocomplete(interaction, ownerModuleId, subcommand.permissionId ??
112
- [registered.contribution.name, groupName, subcommand.name]
113
- .filter(Boolean)
114
- .join("."), services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
112
+ !(await authorizeAutocomplete(interaction, ownerModuleId, resolveDirectCommandPath(registered.contribution, subcommand, groupName).permissionId, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
115
113
  false, subcommand.authorization === "resource-scoped", isInstallationIndependentModule(modulesById, ownerModuleId), subcommand.authorization))) {
116
114
  await response.respond([]);
117
115
  return;
@@ -175,12 +173,7 @@ export function installInteractions(client, modules, registration, logger, servi
175
173
  });
176
174
  return;
177
175
  }
178
- const commandId = subcommandName
179
- ? [command.name, groupName, subcommandName]
180
- .filter(Boolean)
181
- .join(".")
182
- : command.name;
183
- const permissionId = subcommand?.permissionId ?? command.permissionId ?? commandId;
176
+ const { permissionId } = resolveDirectCommandPath(command, subcommand, groupName);
184
177
  const owner = resolveCommandOwner(registered, subcommandName, groupName);
185
178
  const ownerModuleId = owner?.moduleId ?? moduleId;
186
179
  const installationIndependentCommand = isInstallationIndependentModule(modulesById, ownerModuleId);
@@ -515,6 +508,8 @@ function addCommandOption(builder, option) {
515
508
  guild_text: ChannelType.GuildText,
516
509
  guild_announcement: ChannelType.GuildAnnouncement,
517
510
  guild_category: ChannelType.GuildCategory,
511
+ guild_forum: ChannelType.GuildForum,
512
+ guild_media: ChannelType.GuildMedia,
518
513
  };
519
514
  builder.addChannelOption((optionBuilder) => {
520
515
  optionBuilder
@@ -1053,27 +1048,37 @@ export function toReplyOptions(response) {
1053
1048
  ],
1054
1049
  };
1055
1050
  }
1056
- const embed = response.embed
1057
- ? new EmbedBuilder()
1058
- .setTitle(response.embed.title)
1059
- .setDescription(response.embed.description)
1060
- .setColor(response.embed.color ?? 0x7c3aed)
1061
- : undefined;
1062
- if (embed && response.embed?.footer)
1063
- embed.setFooter({ text: response.embed.footer });
1064
- if (embed && response.embed?.url)
1065
- embed.setURL(response.embed.url);
1066
- if (embed && response.embed?.author)
1067
- embed.setAuthor({
1068
- name: response.embed.author.name,
1069
- ...(response.embed.author.iconUrl
1070
- ? { iconURL: response.embed.author.iconUrl }
1071
- : {}),
1072
- });
1073
- if (embed && response.embed?.timestamp)
1074
- embed.setTimestamp(new Date(response.embed.timestamp));
1075
- if (embed && response.embed?.imageUrl)
1076
- embed.setImage(response.embed.imageUrl);
1051
+ if (response.embed && response.embeds)
1052
+ throw new Error("Message responses cannot use both embed and embeds");
1053
+ const embeds = (response.embeds ?? (response.embed ? [response.embed] : [])).map((value) => {
1054
+ const embed = new EmbedBuilder().setColor(value.color ?? 0x7c3aed);
1055
+ if (value.title)
1056
+ embed.setTitle(value.title);
1057
+ if (value.description)
1058
+ embed.setDescription(value.description);
1059
+ if (value.footer)
1060
+ embed.setFooter({
1061
+ text: value.footer,
1062
+ ...(value.footerIconUrl ? { iconURL: value.footerIconUrl } : {}),
1063
+ });
1064
+ if (value.url)
1065
+ embed.setURL(value.url);
1066
+ if (value.author)
1067
+ embed.setAuthor({
1068
+ name: value.author.name,
1069
+ ...(value.author.url ? { url: value.author.url } : {}),
1070
+ ...(value.author.iconUrl ? { iconURL: value.author.iconUrl } : {}),
1071
+ });
1072
+ if (value.timestamp)
1073
+ embed.setTimestamp(new Date(value.timestamp));
1074
+ if (value.imageUrl)
1075
+ embed.setImage(value.imageUrl);
1076
+ if (value.thumbnailUrl)
1077
+ embed.setThumbnail(value.thumbnailUrl);
1078
+ if (value.fields?.length)
1079
+ embed.addFields(...value.fields);
1080
+ return embed;
1081
+ });
1077
1082
  const components = response.buttons?.length
1078
1083
  ? [
1079
1084
  new ActionRowBuilder().addComponents(response.buttons.map((button) => {
@@ -1096,15 +1101,20 @@ export function toReplyOptions(response) {
1096
1101
  : [];
1097
1102
  components.push(...selectRows);
1098
1103
  return {
1099
- ...(response.ephemeral || response.suppressEmbeds
1104
+ ...(response.ephemeral ||
1105
+ response.suppressEmbeds ||
1106
+ response.suppressNotifications
1100
1107
  ? {
1101
1108
  flags: (response.ephemeral ? MessageFlags.Ephemeral : 0) |
1102
- (response.suppressEmbeds ? MessageFlags.SuppressEmbeds : 0),
1109
+ (response.suppressEmbeds ? MessageFlags.SuppressEmbeds : 0) |
1110
+ (response.suppressNotifications
1111
+ ? MessageFlags.SuppressNotifications
1112
+ : 0),
1103
1113
  }
1104
1114
  : {}),
1105
1115
  ...(allowedMentions ? { allowedMentions } : {}),
1106
1116
  ...(response.content === undefined ? {} : { content: response.content }),
1107
- ...(embed ? { embeds: [embed] } : {}),
1117
+ ...(embeds.length > 0 ? { embeds } : {}),
1108
1118
  components,
1109
1119
  };
1110
1120
  }
@@ -1299,6 +1309,10 @@ function discordChannelType(type) {
1299
1309
  return ChannelType.GuildAnnouncement;
1300
1310
  if (type === "guild_category")
1301
1311
  return ChannelType.GuildCategory;
1312
+ if (type === "guild_forum")
1313
+ return ChannelType.GuildForum;
1314
+ if (type === "guild_media")
1315
+ return ChannelType.GuildMedia;
1302
1316
  return ChannelType.GuildText;
1303
1317
  }
1304
1318
  export function safeInteractionFailureMessage(error) {
@@ -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" | "guild_category";
5
+ type: "guild_text" | "guild_announcement" | "guild_category" | "guild_forum" | "guild_media";
6
6
  }[];
7
7
  roles: readonly {
8
8
  id: string;
package/dist/modules.d.ts CHANGED
@@ -20,8 +20,10 @@ export declare function deriveSecureRandomSelectionKey(encodedDataEncryptionKey:
20
20
  export declare function platformServiceOptions(environment: PlatformServiceEnvironment, modules: readonly ModuleDefinition[]): {
21
21
  applicationId?: string;
22
22
  secureRandomSelectionKey?: Uint8Array;
23
+ inviteFingerprintKey?: Uint8Array;
23
24
  dashboardBaseUrl?: string;
24
25
  };
26
+ export declare function deriveInviteFingerprintKey(selectionKey: Uint8Array): Uint8Array;
25
27
  export declare function loadConfiguredModules(specifiers: readonly string[], importer?: ModuleImporter): Promise<ModuleDefinition[]>;
26
28
  export declare function loadConfiguredModulePackages(specifiers: readonly string[], importer?: ModuleImporter, resolvePackageJson?: PackageJsonResolver, readPackageJson?: PackageJsonReader): Promise<ConfiguredModulePackage[]>;
27
29
  export declare function validateContributionParity(definition: ModuleDefinition): void;
package/dist/modules.js CHANGED
@@ -4,10 +4,13 @@ import { createHmac } from "node:crypto";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname } from "node:path";
7
+ import { assertCommandPathContract } from "./command-path-contract.js";
7
8
  const officialModulePackage = /^@helyx\/module-[a-z0-9][a-z0-9-]*$/u;
8
9
  const giveawaysModuleId = "helyx.giveaways";
10
+ const inviteTrackingModuleId = "helyx.invite-tracking";
9
11
  export const secureRandomSelectionWorkerKeyName = "HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY";
10
12
  const secureRandomSelectionKeyContext = "helyx.secure-random-selection.worker.v1";
13
+ const inviteFingerprintKeyContext = "helyx.discord-invite-fingerprint.v1";
11
14
  function decodeSecureRandomSelectionKey(encodedKey, variableName) {
12
15
  const key = Buffer.from(encodedKey, "base64");
13
16
  if (key.byteLength !== 32) {
@@ -30,25 +33,38 @@ export function platformServiceOptions(environment, modules) {
30
33
  : {};
31
34
  const internalSelectionKey = environment.HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY;
32
35
  if (internalSelectionKey) {
36
+ const selectionKey = decodeSecureRandomSelectionKey(internalSelectionKey, secureRandomSelectionWorkerKeyName);
33
37
  return {
34
38
  ...application,
35
39
  ...urls,
36
- secureRandomSelectionKey: decodeSecureRandomSelectionKey(internalSelectionKey, secureRandomSelectionWorkerKeyName),
40
+ secureRandomSelectionKey: selectionKey,
41
+ inviteFingerprintKey: deriveInviteFingerprintKey(selectionKey),
37
42
  };
38
43
  }
39
44
  const encodedKey = environment.HELYX_DATA_ENCRYPTION_KEY;
40
45
  if (!encodedKey) {
41
- if (modules.some(({ manifest }) => manifest.id === giveawaysModuleId)) {
42
- throw new Error("HELYX_DATA_ENCRYPTION_KEY is required when Giveaways is loaded");
46
+ const keyRequired = modules.some(({ manifest }) => manifest.id === giveawaysModuleId ||
47
+ manifest.id === inviteTrackingModuleId);
48
+ if (keyRequired) {
49
+ throw new Error("HELYX_DATA_ENCRYPTION_KEY is required when Giveaways or Invite Tracking is loaded");
43
50
  }
44
51
  return { ...application, ...urls };
45
52
  }
53
+ const selectionKey = deriveSecureRandomSelectionKey(encodedKey);
46
54
  return {
47
55
  ...application,
48
56
  ...urls,
49
- secureRandomSelectionKey: deriveSecureRandomSelectionKey(encodedKey),
57
+ secureRandomSelectionKey: selectionKey,
58
+ inviteFingerprintKey: deriveInviteFingerprintKey(selectionKey),
50
59
  };
51
60
  }
61
+ export function deriveInviteFingerprintKey(selectionKey) {
62
+ if (selectionKey.byteLength !== 32)
63
+ throw new Error("Secure random selection key must contain exactly 32 bytes");
64
+ return createHmac("sha256", selectionKey)
65
+ .update(inviteFingerprintKeyContext, "utf8")
66
+ .digest();
67
+ }
52
68
  function isModuleDefinition(value) {
53
69
  if (typeof value !== "object" || value === null)
54
70
  return false;
@@ -165,13 +181,20 @@ export function validateContributionParity(definition) {
165
181
  ...(definition.events?.guildEmojiUpdate ?? []),
166
182
  ].map(({ type }) => type);
167
183
  const executableLogs = (definition.logging?.events ?? []).map(({ id }) => id);
184
+ const executableSchedulableActions = [
185
+ ...new Set((definition.schedulableActions ?? []).map(({ id }) => id)),
186
+ ];
168
187
  const executableDashboardActions = (definition.dashboardActions ?? []).map(({ id }) => id);
169
188
  const executableManagedResources = (definition.managedResources ?? []).map(({ id }) => id);
170
189
  assertSameContributionIds(definition.manifest.contributions.commands, executableCommands, "commands");
171
190
  assertSameContributionIds(definition.manifest.contributions.events, executableEvents, "events");
172
191
  assertSameContributionIds(definition.manifest.contributions.logs ?? [], executableLogs, "logs");
192
+ assertSameContributionIds(definition.manifest.contributions.schedulableActions ?? [], executableSchedulableActions, "schedulable actions");
173
193
  assertSameContributionIds(definition.manifest.dashboard?.actions?.map(({ id }) => id) ?? [], executableDashboardActions, "dashboard actions");
174
194
  assertSameContributionIds(definition.manifest.dashboard?.resources?.map(({ id }) => id) ?? [], executableManagedResources, "dashboard resources");
195
+ if (definition.manifest.manifestVersion === "0.4") {
196
+ assertCommandPathContract(definition);
197
+ }
175
198
  validatePrivacyParity(definition);
176
199
  }
177
200
  function validatePrivacyParity(definition) {
@@ -2,6 +2,7 @@ import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
2
  import { isCustomerSelectableModule } from "@helyx/module-manifest";
3
3
  import { PermissionFlagsBits } from "discord.js";
4
4
  import { canMutatePermissionPolicy, permissionMutationDenialMessage, } from "@helyx/core";
5
+ import { collectCommandPermissionTargets } from "./command-path-contract.js";
5
6
  import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
6
7
  const MODULE_SELECT_ID = "helyx.permissions.module";
7
8
  const TARGET_SELECT_ID = "helyx.permissions.target";
@@ -129,7 +130,8 @@ export function createPermissionsModule(configurableModules) {
129
130
  if (!module || !target)
130
131
  throw new Error("Permission target expired");
131
132
  const commandId = target === "__module__" ? undefined : target;
132
- if (commandId && !commandTargets(module).includes(commandId)) {
133
+ if (commandId &&
134
+ !commandTargets(module).some(({ id }) => id === commandId)) {
133
135
  throw new Error("Selected command is unavailable");
134
136
  }
135
137
  const current = await context.services
@@ -437,17 +439,12 @@ function restrictedPolicy() {
437
439
  };
438
440
  }
439
441
  function commandTargets(module) {
440
- const targets = (module.interactions?.commands ?? []).flatMap((command) => {
441
- const nested = [
442
- ...(command.subcommands ?? []).map((subcommand) => `${command.name}.${subcommand.name}`),
443
- ...(command.subcommandGroups ?? []).flatMap((group) => group.subcommands.map((subcommand) => `${command.name}.${group.name}.${subcommand.name}`)),
444
- ];
445
- if (nested.length > 0)
446
- return nested;
447
- return [command.name];
448
- });
449
- targets.push(...(module.sharedCommands ?? []).map((command) => command.permissionId ?? `${command.family}.${command.path}`), ...(module.nexusCommands ?? []).map((command) => `nexus.${command.path}`));
450
- return [...new Set(targets)];
442
+ return [
443
+ ...new Map(collectCommandPermissionTargets(module).map(({ id, name }) => [
444
+ id,
445
+ { id, label: name },
446
+ ])).values(),
447
+ ];
451
448
  }
452
449
  function modulePickerResponse(modules, requestedPage) {
453
450
  const pages = pageCount(modules.length, MODULE_PAGE_SIZE);
@@ -483,7 +480,7 @@ function targetPickerResponse(module, targets, requestedPage) {
483
480
  ephemeral: true,
484
481
  embed: {
485
482
  title: `${module.manifest.name} permissions`,
486
- description: "Choose the whole module or one command. Command policies override the module policy." +
483
+ description: "Choose the whole module, a command, or a declared command operation. Target policies override the module policy." +
487
484
  (pages > 1 ? ` Page ${String(page + 1)} of ${String(pages)}.` : ""),
488
485
  color: VIOLET,
489
486
  },
@@ -495,11 +492,11 @@ function targetPickerResponse(module, targets, requestedPage) {
495
492
  {
496
493
  label: "Entire module",
497
494
  value: "__module__",
498
- description: "Default policy for every command",
495
+ description: "Default policy for every command and operation",
499
496
  },
500
497
  ...visible.map((target) => ({
501
- label: `/${target.replaceAll(".", " ")}`,
502
- value: target,
498
+ label: target.label,
499
+ value: target.id,
503
500
  })),
504
501
  ],
505
502
  },
@@ -4,7 +4,7 @@ export const SHARD_CONTRACT_VERSION = 1;
4
4
  // Guild dashboard state includes the bounded module catalogue as well as the
5
5
  // guild's Discord resources. Keep this aligned with the cloud-control message
6
6
  // ceiling so hosted and self-hosted transports accept the same valid state.
7
- export const SHARD_CONTROL_MAX_BYTES = 256 * 1_024;
7
+ export const SHARD_CONTROL_MAX_BYTES = 768 * 1_024;
8
8
  export const SHARD_CONTROL_TIMEOUT_MS = 30_000;
9
9
  export const SHARD_HEALTH_STALE_MS = 30_000;
10
10
  export const SHARD_LEASE_HEARTBEAT_TIMEOUT_MS = 20_000;
@@ -2,7 +2,7 @@ import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
3
  import { checkDatabaseConnection, createDatabasePool } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
- import { createPlatformServices } from "@helyx/platform";
5
+ import { createPlatformServices, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
6
6
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
7
7
  import { Client, Events, Partials } from "discord.js";
8
8
  import { monitorEventLoopDelay } from "node:perf_hooks";
@@ -21,6 +21,8 @@ import { createPermissionsModule } from "./permissions-module.js";
21
21
  import { WorkerCloudConnectorProxy } from "./shard-cloud-proxy.js";
22
22
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
23
23
  import { DiscordGuildHealthInspection } from "./discord-guild-health.js";
24
+ import { DiscordAnalyticsInspection } from "./discord-analytics.js";
25
+ import { DiscordInviteInspection } from "./discord-invites.js";
24
26
  import { CoreResourceAuthorizationService } from "./resource-authorization.js";
25
27
  import { PostgresTriviaDataProvider } from "./trivia-data-provider.js";
26
28
  import { createDeveloperOperationsModule, WorkerShardHealthProvider, } from "./developer-operations-module.js";
@@ -181,8 +183,9 @@ export async function startShardWorker(processEnvironment = process.env, identit
181
183
  ];
182
184
  const platformServices = new Map();
183
185
  const serviceAccess = new ServiceRegistry(platformServices);
186
+ const serviceOptions = platformServiceOptions(environment, publicModules);
184
187
  for (const [name, service] of createPlatformServices(database, publicModules, {
185
- ...platformServiceOptions(environment, publicModules),
188
+ ...serviceOptions,
186
189
  activationServices: serviceAccess,
187
190
  })) {
188
191
  platformServices.set(name, service);
@@ -213,9 +216,14 @@ export async function startShardWorker(processEnvironment = process.env, identit
213
216
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
214
217
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
215
218
  platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
216
- platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
219
+ const memberRoles = new DiscordMemberRoleService(client);
220
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
221
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
217
222
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
218
223
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
224
+ platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
225
+ if (serviceOptions.inviteFingerprintKey)
226
+ platformServices.set(HELYX_SERVICE_NAMES.discordInvites, new DiscordInviteInspection(client, serviceOptions.inviteFingerprintKey));
219
227
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging"))
220
228
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, logger));
221
229
  const scheduledTaskRoute = createLocalScheduledTaskRoute({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.46",
3
+ "version": "0.5.0",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -53,11 +53,11 @@
53
53
  "@helyx/config": ">=0.5.0 <0.6.0",
54
54
  "@helyx/control-protocol": ">=0.4.0 <0.5.0",
55
55
  "@helyx/core": ">=0.1.0 <0.2.0",
56
- "@helyx/database": ">=0.9.0 <0.10.0",
56
+ "@helyx/database": ">=0.10.0",
57
57
  "@helyx/framework": ">=0.1.0 <0.2.0",
58
58
  "@helyx/module-manifest": ">=0.8.0 <0.9.0",
59
- "@helyx/platform": ">=0.6.2 <0.7.0",
60
- "@helyx/sdk": ">=0.9.0 <0.10.0",
59
+ "@helyx/platform": ">=0.7.0",
60
+ "@helyx/sdk": ">=0.10.0",
61
61
  "@helyx/status-client": ">=0.1.0 <0.2.0",
62
62
  "@helyx/verification-contracts": ">=0.3.0 <0.4.0",
63
63
  "discord.js": "14.27.0",