@helyx/bot 0.4.33 → 0.4.36
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 +45 -0
- package/dist/cloud-management-module.js +7 -1
- package/dist/control-server.d.ts +6 -7
- package/dist/control-server.js +38 -45
- package/dist/dashboard-commands.d.ts +10 -0
- package/dist/dashboard-commands.js +47 -0
- package/dist/developer-operations-module.js +5 -1
- package/dist/discord-guild-health.d.ts +4 -3
- package/dist/discord-guild-health.js +6 -3
- package/dist/events.js +38 -16
- package/dist/index.js +43 -3
- package/dist/interactions.d.ts +3 -2
- package/dist/interactions.js +132 -45
- package/dist/internal-module-manifest.d.ts +13 -0
- package/dist/internal-module-manifest.js +14 -0
- package/dist/management-module.js +7 -2
- package/dist/module-availability-control.d.ts +24 -0
- package/dist/module-availability-control.js +61 -0
- package/dist/modules.js +24 -1
- package/dist/nexus-commands.d.ts +13 -0
- package/dist/nexus-commands.js +100 -0
- package/dist/numeric-barriers.d.ts +11 -0
- package/dist/numeric-barriers.js +14 -0
- package/dist/numeric-write-behind-proxy.d.ts +4 -4
- package/dist/numeric-write-behind-proxy.js +45 -24
- package/dist/numeric-write-behind-redis.d.ts +4 -4
- package/dist/numeric-write-behind-redis.js +132 -70
- package/dist/oauth-module.js +3 -1
- package/dist/permissions-module.js +18 -5
- package/dist/renewable-lease.d.ts +16 -0
- package/dist/renewable-lease.js +66 -0
- package/dist/scheduled-task-runner.js +15 -1
- package/dist/shard-router.d.ts +2 -0
- package/dist/shard-router.js +5 -0
- package/dist/shard-supervisor.d.ts +5 -0
- package/dist/shard-supervisor.js +63 -4
- package/dist/shard-worker.d.ts +7 -1
- package/dist/shard-worker.js +4 -1
- package/dist/support-module.js +3 -1
- package/package.json +5 -4
package/dist/events.js
CHANGED
|
@@ -3,6 +3,20 @@ import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, PermissionFla
|
|
|
3
3
|
import { toReplyOptions } from "./interactions.js";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
|
|
6
|
+
function availabilityAwareInstallations(services) {
|
|
7
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
8
|
+
const candidate = typeof services.has === "function" &&
|
|
9
|
+
services.has(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
10
|
+
? services.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
11
|
+
: null;
|
|
12
|
+
const availability = typeof candidate?.isAvailable === "function" ? candidate : null;
|
|
13
|
+
return {
|
|
14
|
+
async isModuleEnabled(guildId, moduleId) {
|
|
15
|
+
return ((!availability || (await availability.isAvailable(moduleId))) &&
|
|
16
|
+
installations.isModuleEnabled(guildId, moduleId));
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
6
20
|
export function installModuleEvents(client, modules, logger, services) {
|
|
7
21
|
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
|
|
8
22
|
moduleId: module.manifest.id,
|
|
@@ -113,7 +127,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
113
127
|
if (!message.guildId || !privilegedEligibilityQueue)
|
|
114
128
|
return;
|
|
115
129
|
privilegedEligibilityQueue.enqueue(message.guildId, async () => {
|
|
116
|
-
const installations = services
|
|
130
|
+
const installations = availabilityAwareInstallations(services);
|
|
117
131
|
const eligible = [];
|
|
118
132
|
for (const item of privilegedMessageContributions) {
|
|
119
133
|
try {
|
|
@@ -260,7 +274,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
260
274
|
});
|
|
261
275
|
}
|
|
262
276
|
async function handleResourceLifecycle(contributions, serverId, identifier, resourceId, eventName) {
|
|
263
|
-
const installations = services
|
|
277
|
+
const installations = availabilityAwareInstallations(services);
|
|
264
278
|
for (const { moduleId, contribution } of contributions) {
|
|
265
279
|
try {
|
|
266
280
|
if (!(await installations.isModuleEnabled(serverId, moduleId))) {
|
|
@@ -286,8 +300,11 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
286
300
|
async function handlePrivilegedMessageCreate(message, contributions) {
|
|
287
301
|
if (!message.guildId || !message.channelId)
|
|
288
302
|
return;
|
|
303
|
+
const installations = availabilityAwareInstallations(services);
|
|
289
304
|
for (const { moduleId, contribution } of contributions) {
|
|
290
305
|
try {
|
|
306
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
|
|
307
|
+
continue;
|
|
291
308
|
await contribution.execute({
|
|
292
309
|
serverId: message.guildId,
|
|
293
310
|
channelId: message.channelId,
|
|
@@ -316,7 +333,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
316
333
|
async function handleGuildMessageCreate(message) {
|
|
317
334
|
if (!message.guildId || !message.channelId)
|
|
318
335
|
return;
|
|
319
|
-
const installations = services
|
|
336
|
+
const installations = availabilityAwareInstallations(services);
|
|
320
337
|
const parentChannelId = message.channel.isThread()
|
|
321
338
|
? (message.channel.parentId ?? undefined)
|
|
322
339
|
: undefined;
|
|
@@ -410,7 +427,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
410
427
|
: reaction.message;
|
|
411
428
|
if (!message.guildId || !message.channelId)
|
|
412
429
|
return;
|
|
413
|
-
const installations = services
|
|
430
|
+
const installations = availabilityAwareInstallations(services);
|
|
414
431
|
const member = await message.guild?.members.fetch(user.id);
|
|
415
432
|
const userRoleIds = member ? [...member.roles.cache.keys()] : [];
|
|
416
433
|
const parentChannelId = messageParentChannelId(message);
|
|
@@ -467,7 +484,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
467
484
|
async function handleMemberAdd(member) {
|
|
468
485
|
if (!member.joinedAt)
|
|
469
486
|
throw new Error("Discord member join timestamp is unavailable");
|
|
470
|
-
const installations = services
|
|
487
|
+
const installations = availabilityAwareInstallations(services);
|
|
471
488
|
for (const { moduleId, contribution } of memberAddContributions) {
|
|
472
489
|
try {
|
|
473
490
|
if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
|
|
@@ -498,7 +515,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
498
515
|
}
|
|
499
516
|
}
|
|
500
517
|
async function handleMemberRemove(member) {
|
|
501
|
-
const installations = services
|
|
518
|
+
const installations = availabilityAwareInstallations(services);
|
|
502
519
|
for (const { moduleId, contribution } of memberRemoveContributions) {
|
|
503
520
|
try {
|
|
504
521
|
if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
|
|
@@ -536,7 +553,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
536
553
|
: reaction.message;
|
|
537
554
|
if (!message.guildId || !message.channelId)
|
|
538
555
|
return;
|
|
539
|
-
const installations = services
|
|
556
|
+
const installations = availabilityAwareInstallations(services);
|
|
540
557
|
const parentChannelId = messageParentChannelId(message);
|
|
541
558
|
for (const { moduleId, contribution } of reactionRemoveContributions) {
|
|
542
559
|
if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
|
|
@@ -580,7 +597,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
580
597
|
: receivedMessage;
|
|
581
598
|
if (!message.guildId || !message.channelId)
|
|
582
599
|
return;
|
|
583
|
-
const installations = services
|
|
600
|
+
const installations = availabilityAwareInstallations(services);
|
|
584
601
|
for (const { moduleId, contribution } of reactionRemoveAllContributions) {
|
|
585
602
|
try {
|
|
586
603
|
if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
|
|
@@ -610,7 +627,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
610
627
|
const message = reaction.message;
|
|
611
628
|
if (!message.guildId || !message.channelId)
|
|
612
629
|
return;
|
|
613
|
-
const installations = services
|
|
630
|
+
const installations = availabilityAwareInstallations(services);
|
|
614
631
|
for (const { moduleId, contribution } of reactionRemoveEmojiContributions) {
|
|
615
632
|
try {
|
|
616
633
|
if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
|
|
@@ -638,7 +655,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
638
655
|
async function handleMessageUpdate(receivedMessage) {
|
|
639
656
|
if (!receivedMessage.guildId || !receivedMessage.channelId)
|
|
640
657
|
return;
|
|
641
|
-
const installations = services
|
|
658
|
+
const installations = availabilityAwareInstallations(services);
|
|
642
659
|
for (const { moduleId, contribution } of messageUpdateContributions) {
|
|
643
660
|
try {
|
|
644
661
|
if (!(await installations.isModuleEnabled(receivedMessage.guildId, moduleId)))
|
|
@@ -672,7 +689,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
672
689
|
async function handleMessageDelete(message) {
|
|
673
690
|
if (!message.guildId || !message.channelId)
|
|
674
691
|
return;
|
|
675
|
-
const installations = services
|
|
692
|
+
const installations = availabilityAwareInstallations(services);
|
|
676
693
|
for (const { moduleId, contribution } of messageDeleteContributions) {
|
|
677
694
|
try {
|
|
678
695
|
if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
|
|
@@ -699,7 +716,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
699
716
|
async function handleMessageDeleteBulk(guildId, channelId, messageIds) {
|
|
700
717
|
if (messageIds.length === 0)
|
|
701
718
|
return;
|
|
702
|
-
const installations = services
|
|
719
|
+
const installations = availabilityAwareInstallations(services);
|
|
703
720
|
const boundedIds = messageIds.slice(0, 100);
|
|
704
721
|
for (const { moduleId, contribution } of messageDeleteBulkContributions) {
|
|
705
722
|
try {
|
|
@@ -733,7 +750,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
733
750
|
voiceSessions.delete(key);
|
|
734
751
|
const endedAt = new Date();
|
|
735
752
|
const durationSeconds = Math.max(1, Math.floor((endedAt.getTime() - previous.startedAt.getTime()) / 1_000));
|
|
736
|
-
const installations = services
|
|
753
|
+
const installations = availabilityAwareInstallations(services);
|
|
737
754
|
for (const { moduleId, contribution } of guildVoiceSessionContributions) {
|
|
738
755
|
try {
|
|
739
756
|
if (!(await installations.isModuleEnabled(previous.guildId, moduleId)))
|
|
@@ -813,13 +830,18 @@ class BoundedGuildEventQueue {
|
|
|
813
830
|
this.#accepting = false;
|
|
814
831
|
if (this.#active === 0 && this.#pending.length === 0)
|
|
815
832
|
return;
|
|
816
|
-
|
|
817
|
-
|
|
833
|
+
const drain = new Promise((resolve) => this.#drainWaiters.add(resolve));
|
|
834
|
+
const drainedBeforeDeadline = await Promise.race([
|
|
835
|
+
drain.then(() => true),
|
|
818
836
|
new Promise((resolve) => {
|
|
819
|
-
const timer = setTimeout(resolve, 5_000);
|
|
837
|
+
const timer = setTimeout(() => resolve(false), 5_000);
|
|
820
838
|
timer.unref();
|
|
821
839
|
}),
|
|
822
840
|
]);
|
|
841
|
+
if (!drainedBeforeDeadline) {
|
|
842
|
+
this.logger.warn({ event: "module.event_stop_deadline_exceeded" }, "Module gateway event queue is still draining after its shutdown deadline");
|
|
843
|
+
await drain;
|
|
844
|
+
}
|
|
823
845
|
this.#pending.length = 0;
|
|
824
846
|
}
|
|
825
847
|
#pump() {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseBotEnvironment } from "@helyx/config";
|
|
2
|
+
import { StatusHeartbeatReporter } from "@helyx/status-client";
|
|
2
3
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
3
4
|
import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
|
|
4
5
|
import { createRuntime } from "@helyx/framework";
|
|
@@ -44,9 +45,45 @@ export async function startBot(processEnvironment = process.env) {
|
|
|
44
45
|
return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
|
|
45
46
|
const environment = parseBotEnvironment(processEnvironment);
|
|
46
47
|
bootstrapLogger.level = environment.LOG_LEVEL;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const handle = environment.HELYX_SHARD_MODE !== "off"
|
|
49
|
+
? await startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint())
|
|
50
|
+
: await startUnshardedBot(processEnvironment);
|
|
51
|
+
if (!environment.HELYX_STATUS_ENABLED)
|
|
52
|
+
return handle;
|
|
53
|
+
const processStartedAt = new Date().toISOString();
|
|
54
|
+
const reporter = new StatusHeartbeatReporter({
|
|
55
|
+
producer: "bot",
|
|
56
|
+
baseUrl: environment.HELYX_STATUS_URL,
|
|
57
|
+
secret: environment.HELYX_STATUS_HEARTBEAT_SECRET,
|
|
58
|
+
accessClientId: environment.CF_ACCESS_CLIENT_ID,
|
|
59
|
+
accessClientSecret: environment.CF_ACCESS_CLIENT_SECRET,
|
|
60
|
+
intervalSeconds: environment.HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS,
|
|
61
|
+
services: () => [
|
|
62
|
+
{
|
|
63
|
+
key: "discord-bot",
|
|
64
|
+
state: "operational",
|
|
65
|
+
processStartedAt,
|
|
66
|
+
release: readBotVersion(),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "creator-connections",
|
|
70
|
+
state: environment.HELYX_OAUTH_ENABLED ? "operational" : "major_outage",
|
|
71
|
+
reasonCode: environment.HELYX_OAUTH_ENABLED ? null : "feature_disabled",
|
|
72
|
+
processStartedAt,
|
|
73
|
+
release: readBotVersion(),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
onError(error) {
|
|
77
|
+
bootstrapLogger.warn({ event: "status.heartbeat_failed", reason: error.message }, "Status heartbeat could not be delivered");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
reporter.start();
|
|
81
|
+
return {
|
|
82
|
+
async shutdown(reason) {
|
|
83
|
+
await reporter.stop();
|
|
84
|
+
await handle.shutdown(reason);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
50
87
|
}
|
|
51
88
|
export function resolveBotWorkerEntryPoint(entryUrl = import.meta.url) {
|
|
52
89
|
return fileURLToPath(entryUrl);
|
|
@@ -254,6 +291,9 @@ async function startUnshardedBot(processEnvironment) {
|
|
|
254
291
|
})) {
|
|
255
292
|
platformServices.set(name, service);
|
|
256
293
|
}
|
|
294
|
+
await serviceAccess
|
|
295
|
+
.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
296
|
+
.refresh();
|
|
257
297
|
if (environment.HELYX_VERIFICATION_ENABLED &&
|
|
258
298
|
environment.HELYX_VERIFICATION_URL &&
|
|
259
299
|
environment.HELYX_VERIFICATION_INTERNAL_SECRET) {
|
package/dist/interactions.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommandContribution, MessageResponse, ModalDefinition, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
|
|
2
2
|
import { ModalBuilder, type ChatInputCommandInteraction, type Client, type InteractionReplyOptions, type InteractionUpdateOptions } from "discord.js";
|
|
3
|
+
export declare const GLOBAL_MODULE_UNAVAILABLE_MESSAGE = "This module is temporarily unavailable for all hosted servers.";
|
|
3
4
|
export interface InteractionLogger {
|
|
4
5
|
info(fields: Record<string, unknown>, message: string): void;
|
|
5
6
|
warn(fields: Record<string, unknown>, message: string): void;
|
|
@@ -16,7 +17,7 @@ export declare function buildApplicationCommandDefinitions(modules: readonly Mod
|
|
|
16
17
|
global: ReturnType<typeof toCommandDefinition>[];
|
|
17
18
|
guild: ReturnType<typeof toCommandDefinition>[];
|
|
18
19
|
};
|
|
19
|
-
export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null): CommandContribution["execute"];
|
|
20
|
+
export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null, groupName?: string | null): CommandContribution["execute"];
|
|
20
21
|
export declare function toCommandDefinition(registered: {
|
|
21
22
|
contribution: CommandContribution;
|
|
22
23
|
}): import("discord.js").RESTPostAPIChatInputApplicationCommandsJSONBody;
|
|
@@ -27,7 +28,7 @@ export declare function sendToChannel(client: Client, channelId: string, respons
|
|
|
27
28
|
export declare function deleteChannelMessage(client: Client, channelId: string, messageId: string): Promise<void>;
|
|
28
29
|
export declare function editChannelMessage(client: Client, channelId: string, messageId: string, response: MessageResponse): Promise<void>;
|
|
29
30
|
export declare function addChannelMessageReaction(client: Client, channelId: string, messageId: string, emojiIdentifier: string): Promise<void>;
|
|
30
|
-
export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean, resourceScopedCommand?: boolean): Promise<boolean>;
|
|
31
|
+
export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean, resourceScopedCommand?: boolean, requiredModuleName?: string): Promise<boolean>;
|
|
31
32
|
export declare function normalizeDiscordPermission(permission: string): string;
|
|
32
33
|
export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
|
|
33
34
|
export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
|
package/dist/interactions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
2
|
import { canUseCommand } from "@helyx/core";
|
|
3
3
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelSelectMenuBuilder, EmbedBuilder, Events, GuildMember, ApplicationIntegrationType, ChannelType, InteractionContextType, LabelBuilder, MessageFlags, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, StringSelectMenuBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, TextDisplayBuilder, PermissionFlagsBits, escapeMarkdown, } from "discord.js";
|
|
4
|
+
import { collectCommandRegistrations, } from "./nexus-commands.js";
|
|
4
5
|
const buttonStyles = {
|
|
5
6
|
primary: ButtonStyle.Primary,
|
|
6
7
|
secondary: ButtonStyle.Secondary,
|
|
@@ -8,11 +9,18 @@ const buttonStyles = {
|
|
|
8
9
|
danger: ButtonStyle.Danger,
|
|
9
10
|
link: ButtonStyle.Link,
|
|
10
11
|
};
|
|
12
|
+
export const GLOBAL_MODULE_UNAVAILABLE_MESSAGE = "This module is temporarily unavailable for all hosted servers.";
|
|
13
|
+
async function moduleIsGloballyAvailable(services, moduleId) {
|
|
14
|
+
if (typeof services.has !== "function" ||
|
|
15
|
+
!services.has(HELYX_SERVICE_NAMES.moduleAvailability))
|
|
16
|
+
return true;
|
|
17
|
+
const availability = services.get(HELYX_SERVICE_NAMES.moduleAvailability);
|
|
18
|
+
return typeof availability?.isAvailable !== "function"
|
|
19
|
+
? true
|
|
20
|
+
: availability.isAvailable(moduleId);
|
|
21
|
+
}
|
|
11
22
|
export function installInteractions(client, modules, registration, logger, services) {
|
|
12
|
-
const commands =
|
|
13
|
-
moduleId: module.manifest.id,
|
|
14
|
-
contribution,
|
|
15
|
-
}))), ({ contribution }) => contribution.name, "command name");
|
|
23
|
+
const commands = collectCommandRegistrations(modules);
|
|
16
24
|
const globalCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "global");
|
|
17
25
|
const guildCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "guild");
|
|
18
26
|
if (guildCommands.length > 0 && !registration.internalGuildId) {
|
|
@@ -74,16 +82,20 @@ export function installInteractions(client, modules, registration, logger, servi
|
|
|
74
82
|
try {
|
|
75
83
|
if (interaction.isAutocomplete()) {
|
|
76
84
|
const registered = commands.get(interaction.commandName);
|
|
77
|
-
const
|
|
85
|
+
const groupName = registered?.contribution.subcommandGroups?.length
|
|
86
|
+
? interaction.options.getSubcommandGroup(false)
|
|
87
|
+
: null;
|
|
88
|
+
const subcommandName = hasNestedCommands(registered?.contribution)
|
|
78
89
|
? interaction.options.getSubcommand(false)
|
|
79
90
|
: null;
|
|
80
|
-
const subcommand = subcommandName
|
|
81
|
-
|
|
82
|
-
: undefined;
|
|
91
|
+
const subcommand = resolveCommandSubcommand(registered?.contribution, subcommandName, groupName);
|
|
92
|
+
const owner = resolveCommandOwner(registered, subcommandName, groupName);
|
|
83
93
|
if (!registered ||
|
|
84
94
|
!subcommand?.autocomplete ||
|
|
85
|
-
!(await authorizeAutocomplete(interaction, registered.moduleId, subcommand.permissionId ??
|
|
86
|
-
|
|
95
|
+
!(await authorizeAutocomplete(interaction, owner?.moduleId ?? registered.moduleId, subcommand.permissionId ??
|
|
96
|
+
[registered.contribution.name, groupName, subcommand.name]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("."), services, registration.selfAuthorizingGuildModuleIds?.has(owner?.moduleId ?? registered.moduleId) ?? false, subcommand.authorization === "resource-scoped"))) {
|
|
87
99
|
await interaction.respond([]);
|
|
88
100
|
return;
|
|
89
101
|
}
|
|
@@ -91,24 +103,35 @@ export function installInteractions(client, modules, registration, logger, servi
|
|
|
91
103
|
}
|
|
92
104
|
else if (interaction.isChatInputCommand()) {
|
|
93
105
|
const registered = commands.get(interaction.commandName);
|
|
94
|
-
if (registered) {
|
|
106
|
+
if (!registered) {
|
|
107
|
+
logger.warn({
|
|
108
|
+
event: "interaction.stale_command",
|
|
109
|
+
commandName: interaction.commandName,
|
|
110
|
+
}, "Interaction used a command that is no longer loaded");
|
|
111
|
+
await interaction.reply({
|
|
112
|
+
content: "This command is no longer available. Refresh Discord and try again.",
|
|
113
|
+
flags: MessageFlags.Ephemeral,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
{
|
|
95
118
|
const { contribution: command, moduleId } = registered;
|
|
96
|
-
const
|
|
119
|
+
const groupName = command.subcommandGroups?.length
|
|
120
|
+
? interaction.options.getSubcommandGroup(false)
|
|
121
|
+
: null;
|
|
122
|
+
const subcommandName = hasNestedCommands(command)
|
|
97
123
|
? interaction.options.getSubcommand(false)
|
|
98
124
|
: null;
|
|
99
|
-
const subcommand = subcommandName
|
|
100
|
-
|
|
101
|
-
: undefined;
|
|
102
|
-
const execute = resolveCommandExecutor(command, subcommandName);
|
|
125
|
+
const subcommand = resolveCommandSubcommand(command, subcommandName, groupName);
|
|
126
|
+
const execute = resolveCommandExecutor(command, subcommandName, groupName);
|
|
103
127
|
if (!execute) {
|
|
104
128
|
logger.warn({
|
|
105
129
|
event: "interaction.stale_command",
|
|
106
130
|
commandName: command.name,
|
|
107
131
|
...(subcommandName ? { subcommandName } : {}),
|
|
132
|
+
...(groupName ? { groupName } : {}),
|
|
108
133
|
}, "Interaction used an outdated command definition");
|
|
109
|
-
const choices = (command.
|
|
110
|
-
.map(({ name }) => `\`/${command.name} ${name}\``)
|
|
111
|
-
.join(" or ");
|
|
134
|
+
const choices = commandChoices(command).join(" or ");
|
|
112
135
|
await interaction.reply({
|
|
113
136
|
content: choices
|
|
114
137
|
? `This command was recently updated. Refresh Discord, then use ${choices}.`
|
|
@@ -118,11 +141,19 @@ export function installInteractions(client, modules, registration, logger, servi
|
|
|
118
141
|
return;
|
|
119
142
|
}
|
|
120
143
|
const commandId = subcommandName
|
|
121
|
-
?
|
|
144
|
+
? [command.name, groupName, subcommandName]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join(".")
|
|
122
147
|
: command.name;
|
|
123
148
|
const permissionId = subcommand?.permissionId ?? command.permissionId ?? commandId;
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
const owner = resolveCommandOwner(registered, subcommandName, groupName);
|
|
150
|
+
const ownerModuleId = owner?.moduleId ?? moduleId;
|
|
151
|
+
const acknowledgement = subcommand?.acknowledgement ?? command.acknowledgement;
|
|
152
|
+
if (acknowledgement === "deferred-ephemeral") {
|
|
153
|
+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
154
|
+
}
|
|
155
|
+
if (await authorizeCommand(interaction, ownerModuleId, permissionId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
|
|
156
|
+
false, subcommand?.authorization === "resource-scoped", owner?.moduleName)) {
|
|
126
157
|
const context = createCommandContext(interaction, services);
|
|
127
158
|
await execute(context);
|
|
128
159
|
}
|
|
@@ -187,10 +218,7 @@ export function installInteractions(client, modules, registration, logger, servi
|
|
|
187
218
|
return registrationReady;
|
|
188
219
|
}
|
|
189
220
|
export function buildApplicationCommandDefinitions(modules, internalGuildId) {
|
|
190
|
-
const commands =
|
|
191
|
-
moduleId: module.manifest.id,
|
|
192
|
-
contribution,
|
|
193
|
-
}))), ({ contribution }) => contribution.name, "command name");
|
|
221
|
+
const commands = collectCommandRegistrations(modules);
|
|
194
222
|
const global = [...commands.values()]
|
|
195
223
|
.filter(({ contribution }) => contribution.registration === "global")
|
|
196
224
|
.map(toCommandDefinition);
|
|
@@ -201,11 +229,11 @@ export function buildApplicationCommandDefinitions(modules, internalGuildId) {
|
|
|
201
229
|
throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
|
|
202
230
|
return { global, guild };
|
|
203
231
|
}
|
|
204
|
-
export function resolveCommandExecutor(command, subcommandName) {
|
|
205
|
-
if (command
|
|
232
|
+
export function resolveCommandExecutor(command, subcommandName, groupName = null) {
|
|
233
|
+
if (hasNestedCommands(command)) {
|
|
206
234
|
if (!subcommandName)
|
|
207
235
|
return undefined;
|
|
208
|
-
const subcommand = command
|
|
236
|
+
const subcommand = resolveCommandSubcommand(command, subcommandName, groupName);
|
|
209
237
|
return subcommand ? (context) => subcommand.execute(context) : undefined;
|
|
210
238
|
}
|
|
211
239
|
return command.execute
|
|
@@ -214,11 +242,25 @@ export function resolveCommandExecutor(command, subcommandName) {
|
|
|
214
242
|
}
|
|
215
243
|
export function toCommandDefinition(registered) {
|
|
216
244
|
const command = registered.contribution;
|
|
217
|
-
|
|
218
|
-
|
|
245
|
+
const hasNested = Boolean(command.subcommands?.length || command.subcommandGroups?.length);
|
|
246
|
+
if (Number(command.execute !== undefined) + Number(hasNested) !== 1) {
|
|
247
|
+
throw new Error(`Command ${command.name} must define exactly one of execute, subcommands or subcommand groups`);
|
|
248
|
+
}
|
|
249
|
+
if (command.execute && !command.acknowledgement) {
|
|
250
|
+
throw new Error(`Command ${command.name} must declare acknowledgement`);
|
|
251
|
+
}
|
|
252
|
+
for (const subcommand of command.subcommands ?? []) {
|
|
253
|
+
if (!subcommand.acknowledgement && !command.acknowledgement)
|
|
254
|
+
throw new Error(`Command ${command.name}.${subcommand.name} must declare acknowledgement`);
|
|
255
|
+
}
|
|
256
|
+
for (const group of command.subcommandGroups ?? []) {
|
|
257
|
+
for (const subcommand of group.subcommands) {
|
|
258
|
+
if (!subcommand.acknowledgement && !command.acknowledgement)
|
|
259
|
+
throw new Error(`Command ${command.name}.${group.name}.${subcommand.name} must declare acknowledgement`);
|
|
260
|
+
}
|
|
219
261
|
}
|
|
220
|
-
if (command.options?.length &&
|
|
221
|
-
throw new Error(`Command ${command.name} cannot define both direct options and
|
|
262
|
+
if (command.options?.length && hasNested) {
|
|
263
|
+
throw new Error(`Command ${command.name} cannot define both direct options and nested commands`);
|
|
222
264
|
}
|
|
223
265
|
const contextTypes = {
|
|
224
266
|
guild: InteractionContextType.Guild,
|
|
@@ -248,8 +290,50 @@ export function toCommandDefinition(registered) {
|
|
|
248
290
|
return subcommandBuilder;
|
|
249
291
|
});
|
|
250
292
|
}
|
|
293
|
+
for (const group of command.subcommandGroups ?? []) {
|
|
294
|
+
builder.addSubcommandGroup((groupBuilder) => {
|
|
295
|
+
groupBuilder.setName(group.name).setDescription(group.description);
|
|
296
|
+
for (const subcommand of group.subcommands) {
|
|
297
|
+
groupBuilder.addSubcommand((subcommandBuilder) => {
|
|
298
|
+
subcommandBuilder
|
|
299
|
+
.setName(subcommand.name)
|
|
300
|
+
.setDescription(subcommand.description);
|
|
301
|
+
for (const option of subcommand.options ?? []) {
|
|
302
|
+
addCommandOption(subcommandBuilder, option);
|
|
303
|
+
}
|
|
304
|
+
return subcommandBuilder;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return groupBuilder;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
251
310
|
return builder.toJSON();
|
|
252
311
|
}
|
|
312
|
+
function hasNestedCommands(command) {
|
|
313
|
+
return Boolean(command?.subcommands?.length || command?.subcommandGroups?.length);
|
|
314
|
+
}
|
|
315
|
+
function resolveCommandSubcommand(command, subcommandName, groupName) {
|
|
316
|
+
if (!command || !subcommandName)
|
|
317
|
+
return undefined;
|
|
318
|
+
if (!groupName) {
|
|
319
|
+
return command.subcommands?.find(({ name }) => name === subcommandName);
|
|
320
|
+
}
|
|
321
|
+
return command.subcommandGroups
|
|
322
|
+
?.find(({ name }) => name === groupName)
|
|
323
|
+
?.subcommands.find(({ name }) => name === subcommandName);
|
|
324
|
+
}
|
|
325
|
+
function resolveCommandOwner(registered, subcommandName, groupName) {
|
|
326
|
+
if (!registered?.routeOwners || !subcommandName)
|
|
327
|
+
return undefined;
|
|
328
|
+
const path = groupName ? `${groupName}.${subcommandName}` : subcommandName;
|
|
329
|
+
return registered.routeOwners.get(path);
|
|
330
|
+
}
|
|
331
|
+
function commandChoices(command) {
|
|
332
|
+
return [
|
|
333
|
+
...(command.subcommands ?? []).map(({ name }) => `\`/${command.name} ${name}\``),
|
|
334
|
+
...(command.subcommandGroups ?? []).flatMap((group) => group.subcommands.map(({ name }) => `\`/${command.name} ${group.name} ${name}\``)),
|
|
335
|
+
];
|
|
336
|
+
}
|
|
253
337
|
function addCommandOption(builder, option) {
|
|
254
338
|
if (option.type === "string") {
|
|
255
339
|
if (option.autocomplete && option.choices?.length)
|
|
@@ -610,14 +694,20 @@ export async function addChannelMessageReaction(client, channelId, messageId, em
|
|
|
610
694
|
const message = await channel.messages.fetch(messageId);
|
|
611
695
|
await message.react(emojiIdentifier);
|
|
612
696
|
}
|
|
613
|
-
async function authorizeComponent(interaction, moduleId, services) {
|
|
697
|
+
async function authorizeComponent(interaction, moduleId, services, requiredModuleName) {
|
|
698
|
+
if (!(await moduleIsGloballyAvailable(services, moduleId))) {
|
|
699
|
+
await replyEphemeralText(interaction, GLOBAL_MODULE_UNAVAILABLE_MESSAGE);
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
614
702
|
if (!interaction.guildId)
|
|
615
703
|
return true;
|
|
616
704
|
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
617
705
|
if (await installations.isModuleEnabled(interaction.guildId, moduleId)) {
|
|
618
706
|
return true;
|
|
619
707
|
}
|
|
620
|
-
await replyEphemeralText(interaction,
|
|
708
|
+
await replyEphemeralText(interaction, requiredModuleName
|
|
709
|
+
? `This command needs ${requiredModuleName}. Ask a server administrator to enable it in the Helyx dashboard.`
|
|
710
|
+
: "This module is disabled in this server. Ask a server administrator to enable it with `/helyx` or in the Helyx dashboard.");
|
|
621
711
|
return false;
|
|
622
712
|
}
|
|
623
713
|
async function replyEphemeralText(interaction, content) {
|
|
@@ -627,11 +717,11 @@ async function replyEphemeralText(interaction, content) {
|
|
|
627
717
|
}
|
|
628
718
|
await interaction.reply({ content, flags: MessageFlags.Ephemeral });
|
|
629
719
|
}
|
|
630
|
-
export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false) {
|
|
720
|
+
export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false, requiredModuleName) {
|
|
631
721
|
if (!interaction.guildId)
|
|
632
722
|
return true;
|
|
633
723
|
if (!selfAuthorizingGuildCommand) {
|
|
634
|
-
if (!(await authorizeComponent(interaction, moduleId, services)))
|
|
724
|
+
if (!(await authorizeComponent(interaction, moduleId, services, requiredModuleName)))
|
|
635
725
|
return false;
|
|
636
726
|
if (!resourceScopedCommand) {
|
|
637
727
|
const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
|
|
@@ -639,10 +729,7 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
|
|
|
639
729
|
const actor = permissionActor(interaction);
|
|
640
730
|
const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
|
|
641
731
|
if (!canUseCommand(actor, policy, guildAccess)) {
|
|
642
|
-
await interaction.
|
|
643
|
-
content: "You do not have permission to use this command.",
|
|
644
|
-
flags: MessageFlags.Ephemeral,
|
|
645
|
-
});
|
|
732
|
+
await replyEphemeralText(interaction, "You do not have permission to use this command.");
|
|
646
733
|
return false;
|
|
647
734
|
}
|
|
648
735
|
}
|
|
@@ -657,16 +744,15 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
|
|
|
657
744
|
windowSeconds: rateLimit.windowSeconds,
|
|
658
745
|
});
|
|
659
746
|
if (!result.allowed) {
|
|
660
|
-
await interaction.
|
|
661
|
-
content: `This command is temporarily rate limited. Try again <t:${Math.ceil(result.resetsAt.getTime() / 1_000)}:R>.`,
|
|
662
|
-
flags: MessageFlags.Ephemeral,
|
|
663
|
-
});
|
|
747
|
+
await replyEphemeralText(interaction, `This command is temporarily rate limited. Try again <t:${Math.ceil(result.resetsAt.getTime() / 1_000)}:R>.`);
|
|
664
748
|
return false;
|
|
665
749
|
}
|
|
666
750
|
}
|
|
667
751
|
return true;
|
|
668
752
|
}
|
|
669
753
|
async function authorizeAutocomplete(interaction, moduleId, commandId, services, selfAuthorizingGuildCommand, resourceScopedCommand) {
|
|
754
|
+
if (!(await moduleIsGloballyAvailable(services, moduleId)))
|
|
755
|
+
return false;
|
|
670
756
|
if (!interaction.guildId || selfAuthorizingGuildCommand)
|
|
671
757
|
return true;
|
|
672
758
|
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
@@ -1023,6 +1109,7 @@ function interactionLogContext(interaction) {
|
|
|
1023
1109
|
...(interaction.isChatInputCommand()
|
|
1024
1110
|
? {
|
|
1025
1111
|
commandName: interaction.commandName,
|
|
1112
|
+
subcommandGroupName: interaction.options.getSubcommandGroup(false) ?? undefined,
|
|
1026
1113
|
subcommandName: interaction.options.getSubcommand(false) ?? undefined,
|
|
1027
1114
|
}
|
|
1028
1115
|
: "customId" in interaction && typeof interaction.customId === "string"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const INTERNAL_MODULE_MANIFEST_FIELDS: {
|
|
2
|
+
readonly manifestVersion: "0.3";
|
|
3
|
+
readonly moduleKind: "internal_dependency";
|
|
4
|
+
readonly customerSelectable: false;
|
|
5
|
+
readonly countsAsInstalledModule: false;
|
|
6
|
+
readonly healthExposure: "delegated";
|
|
7
|
+
readonly privacy: {
|
|
8
|
+
containsPersonalData: false;
|
|
9
|
+
providerVersion: number;
|
|
10
|
+
collections: never[];
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=internal-module-manifest.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const noOwnedPersonalData = {
|
|
2
|
+
containsPersonalData: false,
|
|
3
|
+
providerVersion: 1,
|
|
4
|
+
collections: [],
|
|
5
|
+
};
|
|
6
|
+
export const INTERNAL_MODULE_MANIFEST_FIELDS = {
|
|
7
|
+
manifestVersion: "0.3",
|
|
8
|
+
moduleKind: "internal_dependency",
|
|
9
|
+
customerSelectable: false,
|
|
10
|
+
countsAsInstalledModule: false,
|
|
11
|
+
healthExposure: "delegated",
|
|
12
|
+
privacy: noOwnedPersonalData,
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=internal-module-manifest.js.map
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isCustomerSelectableModule, } from "@helyx/module-manifest";
|
|
1
2
|
import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
|
|
3
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
2
4
|
const MODULE_SELECT_ID = "helyx.management.module";
|
|
3
5
|
const SETTING_SELECT_ID = "helyx.management.setting";
|
|
4
6
|
const VALUE_SELECT_ID = "helyx.management.value";
|
|
@@ -24,11 +26,13 @@ const VIOLET = 0x7c3aed;
|
|
|
24
26
|
const SUCCESS = 0x16a34a;
|
|
25
27
|
const DANGER = 0xdc2626;
|
|
26
28
|
export function createManagementModule(configurableModules, resources) {
|
|
27
|
-
const modules =
|
|
29
|
+
const modules = configurableModules
|
|
30
|
+
.filter((module) => isCustomerSelectableModule(module.manifest))
|
|
31
|
+
.sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
|
|
28
32
|
const byId = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
29
33
|
return defineModule({
|
|
30
34
|
manifest: {
|
|
31
|
-
|
|
35
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
32
36
|
releaseStatus: "live",
|
|
33
37
|
id: "helyx.management",
|
|
34
38
|
name: "Helyx management",
|
|
@@ -49,6 +53,7 @@ export function createManagementModule(configurableModules, resources) {
|
|
|
49
53
|
{
|
|
50
54
|
name: "helyx",
|
|
51
55
|
description: "Manage Helyx modules and settings",
|
|
56
|
+
acknowledgement: "deferred-ephemeral",
|
|
52
57
|
registration: "global",
|
|
53
58
|
contexts: ["guild"],
|
|
54
59
|
integrationTypes: ["guild_install"],
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ModuleAvailabilityService, type ModuleAvailabilityState, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
export interface AvailabilityModuleDefinition {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
releaseStatus: "planned" | "testing" | "beta" | "live";
|
|
7
|
+
}
|
|
8
|
+
export interface AvailabilityModuleCatalogue {
|
|
9
|
+
catalogueDigest: string;
|
|
10
|
+
modules: AvailabilityModuleDefinition[];
|
|
11
|
+
}
|
|
12
|
+
export declare function availabilityCatalogueDefinitions(modules: readonly ModuleDefinition[]): AvailabilityModuleDefinition[];
|
|
13
|
+
export declare function availabilityCatalogueSnapshot(definitions: readonly AvailabilityModuleDefinition[]): AvailabilityModuleCatalogue;
|
|
14
|
+
export declare function availabilityCatalogue(definitions: readonly AvailabilityModuleDefinition[]): Promise<AvailabilityModuleCatalogue>;
|
|
15
|
+
export declare function moduleAvailabilityService(services: ServiceAccess): ModuleAvailabilityService | undefined;
|
|
16
|
+
export declare function moduleAvailabilityStates(services: ServiceAccess, moduleIds: readonly string[]): Promise<ModuleAvailabilityState[]>;
|
|
17
|
+
export declare function dashboardModuleStatus(globallyDisabled: ReadonlySet<string>, moduleId: string, installation: {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
} | undefined): "enabled" | "disabled" | "unavailable";
|
|
20
|
+
export declare function disabledModuleSummary(states: readonly ModuleAvailabilityState[]): {
|
|
21
|
+
ids: string[];
|
|
22
|
+
set: ReadonlySet<string>;
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=module-availability-control.d.ts.map
|