@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.
- package/CHANGELOG.md +28 -0
- package/dist/command-path-contract.d.ts +26 -0
- package/dist/command-path-contract.js +157 -0
- package/dist/control-permissions.js +1 -0
- package/dist/control-server.d.ts +1 -1
- package/dist/control-server.js +8 -2
- package/dist/dashboard-commands.js +5 -35
- package/dist/discord-analytics.d.ts +30 -0
- package/dist/discord-analytics.js +337 -0
- package/dist/discord-invites.d.ts +24 -0
- package/dist/discord-invites.js +91 -0
- package/dist/events.js +49 -2
- package/dist/index.js +11 -3
- package/dist/interactions.js +48 -34
- package/dist/management-module.d.ts +1 -1
- package/dist/modules.d.ts +2 -0
- package/dist/modules.js +27 -4
- package/dist/permissions-module.js +13 -16
- package/dist/shard-contracts.js +1 -1
- package/dist/shard-worker.js +11 -3
- package/package.json +4 -4
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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
|
}
|
package/dist/interactions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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 ||
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
|
|
42
|
-
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
]
|
|
445
|
-
|
|
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
|
|
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:
|
|
502
|
-
value: target,
|
|
498
|
+
label: target.label,
|
|
499
|
+
value: target.id,
|
|
503
500
|
})),
|
|
504
501
|
],
|
|
505
502
|
},
|
package/dist/shard-contracts.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/shard-worker.js
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
-
"@helyx/sdk": ">=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",
|