@helyx/bot 0.1.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/dist/events.js ADDED
@@ -0,0 +1,142 @@
1
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
+ import { Events, } from "discord.js";
3
+ import { toReplyOptions } from "./interactions.js";
4
+ export function installModuleEvents(client, modules, logger, services) {
5
+ const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
6
+ moduleId: module.manifest.id,
7
+ contribution,
8
+ })));
9
+ const memberAddContributions = modules.flatMap((module) => (module.events?.guildMemberAdd ?? []).map((contribution) => ({
10
+ moduleId: module.manifest.id,
11
+ contribution,
12
+ })));
13
+ if (reactionContributions.length === 0 && memberAddContributions.length === 0)
14
+ return;
15
+ if (reactionContributions.length > 0) {
16
+ client.on(Events.MessageReactionAdd, (reaction, user) => {
17
+ void handleReaction(reaction, user);
18
+ });
19
+ }
20
+ if (memberAddContributions.length > 0) {
21
+ client.on(Events.GuildMemberAdd, (member) => {
22
+ void handleMemberAdd(member);
23
+ });
24
+ }
25
+ async function handleReaction(receivedReaction, receivedUser) {
26
+ try {
27
+ const user = receivedUser.partial
28
+ ? await receivedUser.fetch()
29
+ : receivedUser;
30
+ if (user.bot)
31
+ return;
32
+ const reaction = receivedReaction.partial
33
+ ? await receivedReaction.fetch()
34
+ : receivedReaction;
35
+ const message = reaction.message.partial
36
+ ? await reaction.message.fetch()
37
+ : reaction.message;
38
+ if (!message.guildId || !message.channelId)
39
+ return;
40
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
41
+ const member = await message.guild?.members.fetch(user.id);
42
+ const userRoleIds = member ? [...member.roles.cache.keys()] : [];
43
+ for (const { moduleId, contribution } of reactionContributions) {
44
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
45
+ continue;
46
+ }
47
+ try {
48
+ await contribution.execute({
49
+ serverId: message.guildId,
50
+ channelId: message.channelId,
51
+ messageId: message.id,
52
+ userId: receivedUser.id,
53
+ userRoleIds,
54
+ emojiKey: reactionEmojiKey(reaction),
55
+ services,
56
+ removeAddedReaction: () => reaction.users.remove(user.id).then(() => undefined),
57
+ removeUserReaction: async (emojiIdentifier) => {
58
+ const refreshed = await message.fetch();
59
+ const other = refreshed.reactions.cache.get(reactionCacheKey(emojiIdentifier));
60
+ if (other)
61
+ await other.users.remove(user.id);
62
+ },
63
+ });
64
+ }
65
+ catch (error) {
66
+ logger.warn({
67
+ event: "module.reaction_failed",
68
+ moduleId,
69
+ error: safeError(error),
70
+ userId: receivedUser.id,
71
+ }, "Module reaction event failed");
72
+ }
73
+ }
74
+ }
75
+ catch (error) {
76
+ logger.warn({
77
+ event: "module.reaction_failed",
78
+ error: safeError(error),
79
+ userId: receivedUser.id,
80
+ }, "Module reaction event failed");
81
+ }
82
+ }
83
+ async function handleMemberAdd(member) {
84
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
85
+ for (const { moduleId, contribution } of memberAddContributions) {
86
+ try {
87
+ if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
88
+ continue;
89
+ }
90
+ await contribution.execute({
91
+ serverId: member.guild.id,
92
+ serverName: member.guild.name,
93
+ memberId: member.id,
94
+ memberDisplayName: member.displayName,
95
+ memberIsBot: member.user.bot,
96
+ services,
97
+ assignRole: (roleId) => assignMemberRole(member, roleId),
98
+ sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
99
+ });
100
+ }
101
+ catch (error) {
102
+ logger.warn({
103
+ event: "module.member_add_failed",
104
+ moduleId,
105
+ error: safeError(error),
106
+ memberId: member.id,
107
+ guildId: member.guild.id,
108
+ }, "Module member-add event failed");
109
+ }
110
+ }
111
+ }
112
+ }
113
+ async function assignMemberRole(member, roleId) {
114
+ const role = await member.guild.roles.fetch(roleId);
115
+ if (!role || role.id === member.guild.id || role.managed || !role.editable) {
116
+ throw new Error("Configured welcome role cannot be assigned by Helyx");
117
+ }
118
+ await member.roles.add(role, "Helyx Welcome module");
119
+ }
120
+ async function sendToGuildChannel(member, channelId, response) {
121
+ const channel = await member.guild.channels.fetch(channelId);
122
+ if (!channel?.isSendable()) {
123
+ throw new Error("Configured welcome channel is not sendable");
124
+ }
125
+ const options = toReplyOptions({ ...response, ephemeral: false });
126
+ const message = await channel.send(options);
127
+ return { messageId: message.id };
128
+ }
129
+ export function reactionEmojiKey(reaction) {
130
+ return reaction.emoji.id
131
+ ? `custom:${reaction.emoji.id}`
132
+ : `unicode:${(reaction.emoji.name ?? "").normalize("NFKC")}`;
133
+ }
134
+ export function reactionCacheKey(emojiIdentifier) {
135
+ const custom = /^(?:[A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(emojiIdentifier);
136
+ return custom?.[1] ?? emojiIdentifier;
137
+ }
138
+ function safeError(error) {
139
+ const value = error instanceof Error ? error : new Error(String(error));
140
+ return { name: value.name, message: value.message };
141
+ }
142
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,6 @@
1
+ import { type DatabasePool } from "@helyx/database";
2
+ import type { ModuleDefinition } from "@helyx/sdk";
3
+ import { type Client } from "discord.js";
4
+ import type { InteractionLogger } from "./interactions.js";
5
+ export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>): (readyClient: Client<true>) => Promise<void>;
6
+ //# sourceMappingURL=guilds.d.ts.map
package/dist/guilds.js ADDED
@@ -0,0 +1,53 @@
1
+ import { InstallationRepository } from "@helyx/database";
2
+ import { Events } from "discord.js";
3
+ export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set()) {
4
+ const repository = new InstallationRepository(database);
5
+ async function syncGuild(guild) {
6
+ await repository.syncGuild({
7
+ guildId: guild.id,
8
+ ownerUserId: guild.ownerId,
9
+ name: guild.name,
10
+ });
11
+ for (const module of modules) {
12
+ await repository.ensureModule({
13
+ guildId: guild.id,
14
+ moduleId: module.manifest.id,
15
+ moduleVersion: module.manifest.version,
16
+ configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
17
+ enabledBy: guild.ownerId,
18
+ enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
19
+ });
20
+ }
21
+ }
22
+ client.on(Events.GuildCreate, (guild) => {
23
+ void syncGuild(guild).catch((error) => {
24
+ logger.error({
25
+ event: "guild.sync_failed",
26
+ guildId: guild.id,
27
+ error: safeError(error),
28
+ }, "Guild installation sync failed");
29
+ });
30
+ });
31
+ client.on(Events.GuildDelete, (guild) => {
32
+ void repository.markGuildRemoved(guild.id).catch((error) => {
33
+ logger.error({
34
+ event: "guild.remove_failed",
35
+ guildId: guild.id,
36
+ error: safeError(error),
37
+ }, "Guild removal tracking failed");
38
+ });
39
+ });
40
+ return async (readyClient) => {
41
+ for (const guild of readyClient.guilds.cache.values())
42
+ await syncGuild(guild);
43
+ logger.info({
44
+ event: "guilds.synchronised",
45
+ guildCount: readyClient.guilds.cache.size,
46
+ }, "Discord guild installations synchronised");
47
+ };
48
+ }
49
+ function safeError(error) {
50
+ const value = error instanceof Error ? error : new Error(String(error));
51
+ return { name: value.name, message: value.message };
52
+ }
53
+ //# sourceMappingURL=guilds.js.map
@@ -0,0 +1,11 @@
1
+ import { type ModuleDefinition } from "@helyx/sdk";
2
+ import { GatewayIntentBits } from "discord.js";
3
+ export { loadConfiguredModulePackages } from "./modules.js";
4
+ export { resolveModulePackageSpecifiers } from "./module-configuration.js";
5
+ export * from "./control-server.js";
6
+ export interface BotHandle {
7
+ shutdown(reason?: string): Promise<void>;
8
+ }
9
+ export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
10
+ export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[]): GatewayIntentBits[];
11
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,185 @@
1
+ import { parseBotEnvironment } from "@helyx/config";
2
+ import { safeError, ServiceRegistry } from "@helyx/core";
3
+ import { checkDatabaseConnection, createDatabasePool, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
+ import { createRuntime } from "@helyx/framework";
5
+ import { createPlatformServices } from "@helyx/platform";
6
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
7
+ import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
8
+ import pino from "pino";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { installInteractions } from "./interactions.js";
12
+ import { installGuildTracking } from "./guilds.js";
13
+ import { loadConfiguredModulePackages } from "./modules.js";
14
+ import { resolveModulePackageSpecifiers } from "./module-configuration.js";
15
+ import { createPermissionsModule } from "./permissions-module.js";
16
+ import { createManagementModule } from "./management-module.js";
17
+ import { installModuleEvents } from "./events.js";
18
+ import { DiscordModuleLoggingService } from "./module-logging.js";
19
+ import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
20
+ export { loadConfiguredModulePackages } from "./modules.js";
21
+ export { resolveModulePackageSpecifiers } from "./module-configuration.js";
22
+ export * from "./control-server.js";
23
+ const bootstrapLogger = pino({ base: { component: "bot" } });
24
+ export async function startBot(processEnvironment = process.env) {
25
+ const environment = parseBotEnvironment(processEnvironment);
26
+ bootstrapLogger.level = environment.LOG_LEVEL;
27
+ const database = createDatabasePool({
28
+ connectionString: environment.DATABASE_URL,
29
+ sslMode: environment.DATABASE_SSL,
30
+ applicationName: "helyx-bot",
31
+ });
32
+ let databaseClosed = false;
33
+ async function closeDatabase() {
34
+ if (databaseClosed)
35
+ return;
36
+ databaseClosed = true;
37
+ await database.end();
38
+ }
39
+ try {
40
+ await checkDatabaseConnection(database);
41
+ const migrationResult = await runCoreMigrations(database);
42
+ bootstrapLogger.info({
43
+ event: "database.ready",
44
+ appliedMigrations: migrationResult.applied,
45
+ verifiedMigrationCount: migrationResult.verified.length,
46
+ }, "Core database ready");
47
+ const moduleSpecifiers = await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH);
48
+ const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
49
+ const moduleMigrationResults = await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
50
+ moduleId: definition.manifest.id,
51
+ moduleVersion: definition.manifest.version,
52
+ migrationsDirectory: join(packageRoot, "migrations"),
53
+ })));
54
+ bootstrapLogger.info({
55
+ event: "database.modules_ready",
56
+ modules: moduleMigrationResults.map((result) => ({
57
+ moduleId: result.moduleId,
58
+ appliedMigrations: result.applied,
59
+ verifiedMigrationCount: result.verified.length,
60
+ })),
61
+ }, "Module databases ready");
62
+ const featureModules = modulePackages.map(({ definition }) => definition);
63
+ const client = new Client({
64
+ intents: gatewayIntentsForModules(featureModules),
65
+ partials: [Partials.Message, Partials.Channel, Partials.Reaction],
66
+ allowedMentions: { parse: [] },
67
+ });
68
+ const guildResources = new DiscordGuildResourceDirectory(client);
69
+ const permissionsModule = createPermissionsModule(featureModules);
70
+ const managementModule = createManagementModule(featureModules, guildResources);
71
+ const modules = [managementModule, permissionsModule, ...featureModules];
72
+ const platformServices = new Map(createPlatformServices(database, modules));
73
+ if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
74
+ platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, modules, bootstrapLogger));
75
+ }
76
+ const serviceAccess = new ServiceRegistry(platformServices);
77
+ const runtime = createRuntime({
78
+ modules,
79
+ logger: bootstrapLogger,
80
+ services: platformServices,
81
+ });
82
+ const prepareGuilds = installGuildTracking(client, database, modules, bootstrapLogger, new Set([managementModule.manifest.id, permissionsModule.manifest.id]));
83
+ const commandsReady = installInteractions(client, modules, {
84
+ ...(environment.DISCORD_INTERNAL_GUILD_ID
85
+ ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
86
+ : {}),
87
+ prepare: prepareGuilds,
88
+ }, bootstrapLogger, serviceAccess);
89
+ installModuleEvents(client, modules, bootstrapLogger, serviceAccess);
90
+ let controlServer;
91
+ let shuttingDown = false;
92
+ async function shutdown(reason = "programmatic") {
93
+ if (shuttingDown)
94
+ return;
95
+ shuttingDown = true;
96
+ process.off("SIGINT", onSigint);
97
+ process.off("SIGTERM", onSigterm);
98
+ bootstrapLogger.info({ event: "bot.shutdown", reason }, "Bot shutdown requested");
99
+ if (controlServer)
100
+ await controlServer.close().catch((error) => {
101
+ process.exitCode = 1;
102
+ bootstrapLogger.error({ event: "control.stop_failed", error: safeError(error) }, "Private bot control shutdown failed");
103
+ });
104
+ try {
105
+ await runtime.stop();
106
+ }
107
+ catch (error) {
108
+ process.exitCode = 1;
109
+ bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
110
+ }
111
+ await client.destroy();
112
+ await closeDatabase();
113
+ }
114
+ client.once(Events.ClientReady, (readyClient) => {
115
+ bootstrapLogger.info({ event: "discord.ready", userId: readyClient.user.id }, "Discord client ready");
116
+ });
117
+ const onSigint = () => void shutdown("SIGINT");
118
+ const onSigterm = () => void shutdown("SIGTERM");
119
+ process.once("SIGINT", onSigint);
120
+ process.once("SIGTERM", onSigterm);
121
+ try {
122
+ await runtime.start();
123
+ await client.login(environment.DISCORD_TOKEN);
124
+ await commandsReady;
125
+ if (environment.HELYX_BOT_CONTROL_SECRET) {
126
+ controlServer = await createBotControlServer({
127
+ service: new BotControlService(database, modules, guildResources, serviceAccess),
128
+ secret: environment.HELYX_BOT_CONTROL_SECRET,
129
+ logger: { level: environment.LOG_LEVEL },
130
+ });
131
+ await controlServer.listen({
132
+ host: "::",
133
+ port: environment.HELYX_BOT_CONTROL_PORT,
134
+ });
135
+ bootstrapLogger.info({
136
+ event: "control.ready",
137
+ port: environment.HELYX_BOT_CONTROL_PORT,
138
+ }, "Private bot control ready");
139
+ }
140
+ else {
141
+ bootstrapLogger.warn({ event: "control.disabled" }, "Private bot control is disabled; dashboard configuration is unavailable");
142
+ }
143
+ }
144
+ catch (error) {
145
+ if (controlServer)
146
+ await controlServer.close().catch(() => undefined);
147
+ try {
148
+ await runtime.stop();
149
+ }
150
+ catch (stopError) {
151
+ bootstrapLogger.error({ event: "runtime.cleanup_failed", error: safeError(stopError) }, "Runtime cleanup failed");
152
+ }
153
+ await client.destroy();
154
+ throw error;
155
+ }
156
+ return { shutdown };
157
+ }
158
+ catch (error) {
159
+ await closeDatabase().catch((closeError) => {
160
+ bootstrapLogger.error({ event: "database.close_failed", error: safeError(closeError) }, "Database pool cleanup failed");
161
+ });
162
+ throw error;
163
+ }
164
+ }
165
+ export function gatewayIntentsForModules(modules) {
166
+ const intents = [GatewayIntentBits.Guilds];
167
+ if (modules.some((module) => module.events?.messageReactionAdd?.length)) {
168
+ intents.push(GatewayIntentBits.GuildMessageReactions);
169
+ }
170
+ if (modules.some((module) => module.events?.guildMemberAdd?.length)) {
171
+ intents.push(GatewayIntentBits.GuildMembers);
172
+ }
173
+ return intents;
174
+ }
175
+ if (process.argv[1] &&
176
+ import.meta.url === pathToFileURL(process.argv[1]).href) {
177
+ try {
178
+ await startBot();
179
+ }
180
+ catch (error) {
181
+ bootstrapLogger.fatal({ event: "bot.start_failed", error: safeError(error) }, "Bot startup failed");
182
+ process.exitCode = 1;
183
+ }
184
+ }
185
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,26 @@
1
+ import type { CommandContribution, MessageResponse, ModalDefinition, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
2
+ import { ModalBuilder, type Client, type InteractionReplyOptions, type InteractionUpdateOptions } from "discord.js";
3
+ export interface InteractionLogger {
4
+ info(fields: Record<string, unknown>, message: string): void;
5
+ warn(fields: Record<string, unknown>, message: string): void;
6
+ error(fields: Record<string, unknown>, message: string): void;
7
+ }
8
+ export interface CommandRegistration {
9
+ internalGuildId?: string;
10
+ prepare?(client: Client<true>): Promise<void>;
11
+ }
12
+ export declare function installInteractions(client: Client, modules: readonly ModuleDefinition[], registration: CommandRegistration, logger: InteractionLogger, services: ServiceAccess): Promise<void>;
13
+ export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null): CommandContribution["execute"];
14
+ export declare function toCommandDefinition(registered: {
15
+ contribution: CommandContribution;
16
+ }): import("discord.js").RESTPostAPIChatInputApplicationCommandsJSONBody;
17
+ export declare function uniqueMap<T>(items: readonly T[], keyOf: (item: T) => string, label: string): Map<string, T>;
18
+ export declare function sendToChannel(client: Client, channelId: string, response: MessageResponse): Promise<{
19
+ messageId: string;
20
+ }>;
21
+ export declare function deleteChannelMessage(client: Client, channelId: string, messageId: string): Promise<void>;
22
+ export declare function normalizeDiscordPermission(permission: string): string;
23
+ export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
24
+ export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
25
+ export declare function toModal(definition: ModalDefinition): ModalBuilder;
26
+ //# sourceMappingURL=interactions.d.ts.map