diff --git a/adapters/discord/.gitignore b/adapters/discord/.gitignore index a14702c..73bcc54 100644 --- a/adapters/discord/.gitignore +++ b/adapters/discord/.gitignore @@ -21,6 +21,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .env.test.local .env.production.local .env.local +.env.dev +.env.prod # caches .eslintcache diff --git a/adapters/discord/index.ts b/adapters/discord/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/adapters/discord/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/adapters/discord/package.json b/adapters/discord/package.json index 86a5b04..8ed1402 100644 --- a/adapters/discord/package.json +++ b/adapters/discord/package.json @@ -1,16 +1,30 @@ { "name": "@avocadi/bot-adapter-discord", - "module": "index.ts", "type": "module", "private": true, + "scripts": { + "build": "tsdown", + "dev:prod": "NODE_ENV=production tsdown --watch & node --watch ./dist/index.js", + "dev": "NODE_ENV=development tsdown --watch & node --watch ./dist/index.js" + }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "tsdown": "catalog:" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { - "zod": "catalog:", - "@avocadi/bot-core": "workspace:*" + "@avocadi/bot-core": "workspace:*", + "@discordjs/rest": "^2.6.0", + "cron": "^4.4.0", + "discord.js": "^14.25.1", + "dotenv": "^17.3.1", + "dotenv-expand": "^12.0.3", + "zod": "catalog:" + }, + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" } } diff --git a/adapters/discord/src/actions/publish-commands.ts b/adapters/discord/src/actions/publish-commands.ts new file mode 100644 index 0000000..372e034 --- /dev/null +++ b/adapters/discord/src/actions/publish-commands.ts @@ -0,0 +1,18 @@ +import { config } from "config"; +import { Routes } from "discord.js"; +import getCommands from "entitites/commands"; +import { discordRestClient } from "lib/rest-client"; + +export const publishCommands = async () => { + try { + await discordRestClient.put( + Routes.applicationCommands(config.discord.applicationId), + { + body: getCommands(), + }, + ); + console.log("Successfully added commands"); + } catch (e) { + console.error(e); + } +}; diff --git a/adapters/discord/src/config/config.schema.ts b/adapters/discord/src/config/config.schema.ts index c69ea92..3d5beaa 100644 --- a/adapters/discord/src/config/config.schema.ts +++ b/adapters/discord/src/config/config.schema.ts @@ -11,6 +11,9 @@ export const ConfigSchema = z.object({ voice: z.record(VoiceChannels, z.string()), }), roleMapping: z.record(Roles, z.string()), + reactionRoles: z.object({ + allowedMessageIds: z.array(z.string()), + }), serverId: z.string(), version: z.number(), discord: z.object({ diff --git a/adapters/discord/src/config/env/env.schema.ts b/adapters/discord/src/config/env/env.schema.ts new file mode 100644 index 0000000..7a0ee9e --- /dev/null +++ b/adapters/discord/src/config/env/env.schema.ts @@ -0,0 +1,6 @@ +import z from "zod"; + +export const EnvSchema = z.object({ + DISCORD_APPLICATION_ID: z.string(), + DISCORD_TOKEN: z.string(), +}); diff --git a/adapters/discord/src/config/env/index.ts b/adapters/discord/src/config/env/index.ts new file mode 100644 index 0000000..68d5e9f --- /dev/null +++ b/adapters/discord/src/config/env/index.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import dotenv from "dotenv"; +import dotenvExpand from "dotenv-expand"; +import { EnvSchema } from "./env.schema"; + +const envFile = + process.env.NODE_ENV === "production" ? ".env.prod" : ".env.dev"; + +const envPath = join(process.cwd(), envFile); + +const rawEnv = Buffer.from( + readFileSync(envPath, { + encoding: "utf8", + }), +); + +const envJson = { processEnv: dotenv.parse(rawEnv) }; + +const targetObj = {}; + +const expandedEnv = dotenvExpand.expand({ + processEnv: targetObj, + parsed: envJson.processEnv, +}) as { processEnv: Record }; + +const env = EnvSchema.parse(expandedEnv.processEnv); + +export default env; diff --git a/adapters/discord/src/config/config.ts b/adapters/discord/src/config/index.ts similarity index 84% rename from adapters/discord/src/config/config.ts rename to adapters/discord/src/config/index.ts index 53ff911..fc8bb5a 100644 --- a/adapters/discord/src/config/config.ts +++ b/adapters/discord/src/config/index.ts @@ -1,5 +1,6 @@ import type z from "zod"; import type { ConfigSchema } from "./config.schema"; +import env from "./env"; export const config: z.output = { channelMapping: { @@ -34,10 +35,15 @@ export const config: z.output = { people: "1321470720424939662", bot: "1321491461111283722", }, + reactionRoles: { + allowedMessageIds: [ + "1321491461111283722", // Example message ID for reaction roles + ], + }, serverId: "1316153371899592774", version: 1, discord: { - token: process.env.DISCORD_TOKEN || "", - applicationId: process.env.DISCORD_APPLICATION_ID || "", + token: env.DISCORD_TOKEN, + applicationId: env.DISCORD_APPLICATION_ID, }, }; diff --git a/core/src/discord.controller.ts b/adapters/discord/src/discord.controller.ts similarity index 70% rename from core/src/discord.controller.ts rename to adapters/discord/src/discord.controller.ts index f5af883..ca50800 100644 --- a/core/src/discord.controller.ts +++ b/adapters/discord/src/discord.controller.ts @@ -1,74 +1,16 @@ -import { Commands, type CommandsType } from "commands"; -import { - ChannelType, - Client, - Events, - IntentsBitField, - type VoiceState, - type ButtonInteraction, - type CacheType, - type ChatInputCommandInteraction, - type Interaction, - type ModalSubmitInteraction, - ActivityType, - type Channel, -} from "discord.js"; -import client from "lib/client"; import EventEmitter from "node:events"; -import DiscordService from "discord.service"; -import { WaterMeService } from "actions/waterMe/waterMe.service"; -import { MedicationService } from "actions/medication/medication.service"; -import { HelpService } from "actions/help/help.service"; -import { SupportService } from "actions/support/support.service"; -import { GreetingService } from "actions/greeting/greeting.service"; -import { ActivityService } from "actions/activity/activity.service"; -import { DmService } from "actions/dm/dm.service"; -import { CustomMessageService } from "actions/customMessage/customMessage.service"; -import { DynamicVChannelService } from "actions/dynamicVChannel/dynamicVChannel.service"; -import { DebugService } from "actions/debug/debug.service"; -import { ReactRolesService } from "actions/reactRole/reactRoles.service"; -import config from "config"; +import { + type WaterMeController, + waterMeController, +} from "features/water-me/water-me.controller"; +import client from "lib/client"; export default class DiscordController extends EventEmitter { - private discordService: DiscordService; - private waterMeService: WaterMeService; - private greetingService: GreetingService; - private medicationService: MedicationService; - private helpService: HelpService; - private supportService: SupportService; - private activityService: ActivityService; - private dmService: DmService; - private customMessageService: CustomMessageService; - private dynamicVChannelService: DynamicVChannelService; - private debugService: DebugService; - - private channelListeners = new Map(); - private reactRolesService: ReactRolesService; - + private waterMeController: WaterMeController = waterMeController; constructor() { super(); let channelListeners = new Map(); - this.discordService = new DiscordService(); - this.waterMeService = new WaterMeService(); - this.greetingService = new GreetingService(); - this.medicationService = new MedicationService(); - this.helpService = new HelpService(); - this.supportService = new SupportService(); - this.activityService = new ActivityService(); - this.dmService = new DmService(); - this.customMessageService = new CustomMessageService(); - this.dynamicVChannelService = new DynamicVChannelService(); - this.debugService = new DebugService(); - this.reactRolesService = new ReactRolesService(); - - client.on("messageReactionAdd", async (reaction, user) => { - await this.reactRolesService.roleMention(reaction, user, true); - }); - - client.on("messageReactionRemove", async (reaction, user) => { - await this.reactRolesService.roleMention(reaction, user, false); - }); // log when running client.once("ready", async () => { @@ -146,10 +88,11 @@ export default class DiscordController extends EventEmitter { return; } try { - const newChannel = await this.dynamicVChannelService.createVChannel( - newState, - channel, - ); + const newChannel = + await this.dynamicVChannelService.createVChannel( + newState, + channel, + ); // move user in new channel await newState.setChannel(newChannel); // create specific listener for channel @@ -177,7 +120,9 @@ export default class DiscordController extends EventEmitter { } }, ); - console.log(`----------------\nversion ${config.discord.version}\n----------------`); + console.log( + `----------------\nversion ${config.discord.version}\n----------------`, + ); } async setActivity(state: number) { @@ -213,7 +158,6 @@ export default class DiscordController extends EventEmitter { console.log("bot is offline."); } - async init() { await this.discordService.init(); } diff --git a/adapters/discord/src/entitites/commands/index.ts b/adapters/discord/src/entitites/commands/index.ts new file mode 100644 index 0000000..127416c --- /dev/null +++ b/adapters/discord/src/entitites/commands/index.ts @@ -0,0 +1,32 @@ +import { CommandsCollection } from "@avocadi/bot-core/entities/commands/commands.entity"; +import type { Command } from "@avocadi/bot-core/entities/commands/commands.schema"; +import { SlashCommandBuilder } from "discord.js"; +import type { z } from "zod"; + +const convertCommandToDiscordFormat = ( + command: z.output, + key: string, +) => { + const slashCommand = new SlashCommandBuilder() + .setName(command.name || key) + .setDescription(command.description); + + if (command.options) { + command.options.forEach((option) => { + slashCommand.addStringOption((opt) => + opt + .setName(option.name) + .setDescription(option.description) + .setRequired(option.required), + ); + }); + } + + return slashCommand; +}; + +export default function getCommands() { + return Object.entries(CommandsCollection).map(([key, command]) => + convertCommandToDiscordFormat(command, key), + ); +} diff --git a/adapters/discord/src/entitites/messages/messages.service.ts b/adapters/discord/src/entitites/messages/messages.service.ts new file mode 100644 index 0000000..bd3d730 --- /dev/null +++ b/adapters/discord/src/entitites/messages/messages.service.ts @@ -0,0 +1,20 @@ +import type { MessagesServiceInterface } from "@avocadi/bot-core/entities/messages/messages.service"; +import { createLogger } from "@avocadi/bot-core/lib/logger"; +import type { User } from "discord.js"; +import client from "lib/client"; + +export class MessagesService implements MessagesServiceInterface { + private logger = createLogger("MessagesService"); + + async sendToUser(userInput: User, message: string): Promise { + const user = await client.users.fetch(userInput.id); + + if (user) { + await user.send(message); + } else { + this.logger.error(`User with ID ${userInput.id} not found.`); + } + } +} + +export const messagesService = new MessagesService(); diff --git a/adapters/discord/src/entitites/roles/roles.service.ts b/adapters/discord/src/entitites/roles/roles.service.ts new file mode 100644 index 0000000..f126332 --- /dev/null +++ b/adapters/discord/src/entitites/roles/roles.service.ts @@ -0,0 +1,33 @@ +import type { RolesServiceInterface } from "@avocadi/bot-core/entities/roles/roles.service"; +import { createLogger } from "@avocadi/bot-core/lib/logger"; +import type { GuildMember } from "discord.js"; + +export class RolesService implements RolesServiceInterface { + private logger = createLogger("RolesService"); + + async assignRole(user: GuildMember, role: string) { + const roleToAssign = user.guild.roles.cache.find((r) => r.name === role); + if (!roleToAssign) { + this.logger.error(`Role ${role} not found in guild ${user.guild.name}.`); + return; + } + await user.roles.add(roleToAssign); + } + + async removeRole(user: GuildMember, role: string) { + const roleToRemove = user.guild.roles.cache.find((r) => r.name === role); + if (!roleToRemove) { + this.logger.error(`Role ${role} not found in guild ${user.guild.name}.`); + return; + } + await user.roles.remove(roleToRemove); + } + + async getRoles(user: GuildMember) { + return user.roles.cache.map((role) => role.name); + } + + async hasRole(user: GuildMember, role: string) { + return user.roles.cache.some((r) => r.name === role); + } +} diff --git a/adapters/discord/src/features/greeting/greetings.service.ts b/adapters/discord/src/features/greeting/greetings.service.ts new file mode 100644 index 0000000..491073e --- /dev/null +++ b/adapters/discord/src/features/greeting/greetings.service.ts @@ -0,0 +1,4 @@ +import { GreetingService } from "@avocadi/bot-core/features/greeting/greeting.service"; +import { messagesService } from "entitites/messages/messages.service"; + +export const greetingsService = new GreetingService(messagesService); diff --git a/adapters/discord/src/features/reaction-roles/reaction-roles.service.ts b/adapters/discord/src/features/reaction-roles/reaction-roles.service.ts new file mode 100644 index 0000000..c3dc129 --- /dev/null +++ b/adapters/discord/src/features/reaction-roles/reaction-roles.service.ts @@ -0,0 +1,128 @@ +import type { Roles } from "@avocadi/bot-core/entities/roles/roles.schema"; +import { createLogger } from "@avocadi/bot-core/lib/logger"; +import { config } from "config"; +import type { + GuildMember, + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from "discord.js"; +import type z from "zod"; + +export class ReactionRolesService { + private logger = createLogger("ReactionRolesService"); + + /** + * This method validates if the reaction is on an allowed message for reaction roles. + * @param reaction + * @returns + */ + async validateReaction(reaction: MessageReaction | PartialMessageReaction) { + this.logger.info( + `Validating reaction ${reaction.emoji.name} on message ${reaction.message.id}`, + ); + + const message = await reaction.message.fetch(); + + if (!message) { + this.logger.error(`Message with ID ${reaction.message.id} not found.`); + throw new Error("Message not found"); + } + + if (!config.reactionRoles.allowedMessageIds.includes(message.id)) { + this.logger.error( + `Message with ID ${message.id} is not allowed for reaction roles.`, + ); + throw new Error("Message not allowed for reaction roles"); + } + + return; + } + + /** + * Takes a reaction, validates it, and assigns or removes the given role. + * @param reaction + * @param guild + * @param user + * @param role + */ + async handleReaction( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + targetRole: z.output, + action: "add" | "remove", + ) { + const guild = reaction.message.guild; + + if (!guild) { + this.logger.error(`Guild not found for message ${reaction.message.id}.`); + throw new Error("Guild not found"); + } + + await this.validateReaction(reaction); + + const role = guild.roles.cache.get(config.roleMapping[targetRole]); + + if (!role) { + this.logger.error(`Role ${targetRole} not found in guild ${guild.name}.`); + throw new Error("Role not found"); + } + + const member = await guild.members.fetch(user.id); + + if (!member) { + this.logger.error( + `User with ID ${user.id} not found in guild ${guild.name}.`, + ); + throw new Error("User not found"); + } + + if (member.roles.cache.has(role.id)) { + if (action === "remove") { + this.removeRoleByReaction(reaction, member, targetRole); + } else { + this.logger.info( + `User ${member.user.tag} already has role ${targetRole}. No action taken.`, + ); + } + return; + } + + if (action === "remove") { + this.logger.info( + `User ${member.user.tag} does not have role ${targetRole}. No action taken.`, + ); + + return; + } + + this.assignRoleByReaction(reaction, member, targetRole); + + return; + } + + async assignRoleByReaction( + reaction: MessageReaction | PartialMessageReaction, + member: GuildMember, + role: z.output, + ) { + this.logger.info( + `Assigning role ${role} based on reaction ${reaction.emoji.name} by user ${member.user.tag}`, + ); + + await member.roles.add(config.roleMapping[role]); + } + + async removeRoleByReaction( + reaction: MessageReaction | PartialMessageReaction, + member: GuildMember, + role: z.output, + ) { + this.logger.info( + `Removing role ${role} based on reaction ${reaction.emoji.name} by user ${member.user.tag}`, + ); + + await member.roles.remove(config.roleMapping[role]); + } +} diff --git a/adapters/discord/src/features/water-me/water-me.controller.ts b/adapters/discord/src/features/water-me/water-me.controller.ts new file mode 100644 index 0000000..91be47c --- /dev/null +++ b/adapters/discord/src/features/water-me/water-me.controller.ts @@ -0,0 +1,41 @@ +import type { WaterMeService } from "@avocadi/bot-core/features/water-me/water-me.service"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type CacheType, + type Interaction, +} from "discord.js"; +import { waterMeService } from "./water-me.service"; + +class WaterMeController { + waterMeService: WaterMeService; + + constructor() { + this.waterMeService = waterMeService; + } + + async handleInteraction(interaction: Interaction) { + const result = this.waterMeService.waterMe(); + + const moreButton = new ButtonBuilder() + .setCustomId("moreWater") + .setLabel("mehr") + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents(moreButton); + + if (interaction.isChatInputCommand()) { + await interaction.reply({ + content: result.reply, + components: [row], + }); + } else if (interaction.isButton()) { + await interaction.reply({ + content: result.reply, + }); + } + } +} + +export const waterMeController = new WaterMeController(); diff --git a/adapters/discord/src/features/water-me/water-me.service.ts b/adapters/discord/src/features/water-me/water-me.service.ts new file mode 100644 index 0000000..cdaf70f --- /dev/null +++ b/adapters/discord/src/features/water-me/water-me.service.ts @@ -0,0 +1,8 @@ +import { WaterMeService } from "@avocadi/bot-core/features/water-me/water-me.service"; + +export const waterMeService = new WaterMeService({ + channelId: "123", + handleMessageSend: async (message, channelId) => { + console.log(`Sending message to channel ${channelId}: ${message}`); + }, +}); diff --git a/adapters/discord/src/features/water-me/water-me.tasks.ts b/adapters/discord/src/features/water-me/water-me.tasks.ts new file mode 100644 index 0000000..a97523b --- /dev/null +++ b/adapters/discord/src/features/water-me/water-me.tasks.ts @@ -0,0 +1,12 @@ +import { CronJob } from "cron"; +import { waterMeService } from "./water-me.service"; + +new CronJob( + "0 0 20 * * *", // cronTime + async () => { + await waterMeService.notifyIfThirsty(); + }, // onTick + null, // onComplete + true, // start + "Europe/Berlin", // timeZone +); diff --git a/adapters/discord/src/index.ts b/adapters/discord/src/index.ts new file mode 100644 index 0000000..e7612fb --- /dev/null +++ b/adapters/discord/src/index.ts @@ -0,0 +1,4 @@ +import { publishCommands } from "actions/publish-commands"; + +// Publish commands when the application starts +await publishCommands(); diff --git a/adapters/discord/src/lib/client.ts b/adapters/discord/src/lib/client.ts new file mode 100644 index 0000000..3117f97 --- /dev/null +++ b/adapters/discord/src/lib/client.ts @@ -0,0 +1,24 @@ +import { config } from "config"; +import { Client, IntentsBitField, Partials } from "discord.js"; + +const client = new Client({ + intents: [ + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.GuildMembers, + IntentsBitField.Flags.GuildModeration, + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.GuildMessageReactions, + IntentsBitField.Flags.GuildMessagePolls, + IntentsBitField.Flags.GuildVoiceStates, + IntentsBitField.Flags.MessageContent, + IntentsBitField.Flags.DirectMessages, + IntentsBitField.Flags.DirectMessageReactions, + IntentsBitField.Flags.DirectMessageTyping, + IntentsBitField.Flags.DirectMessagePolls, + ], + partials: [Partials.Channel, Partials.Message, Partials.Reaction], +}); + +await client.login(config.discord.token); + +export default client; diff --git a/adapters/discord/src/lib/rest-client.ts b/adapters/discord/src/lib/rest-client.ts new file mode 100644 index 0000000..23b17af --- /dev/null +++ b/adapters/discord/src/lib/rest-client.ts @@ -0,0 +1,6 @@ +import { REST } from "@discordjs/rest"; +import { config } from "config"; + +export const discordRestClient = new REST({ version: "10" }).setToken( + config.discord.token, +); diff --git a/adapters/discord/src/listeners/guild-member/guild-member.listener.ts b/adapters/discord/src/listeners/guild-member/guild-member.listener.ts new file mode 100644 index 0000000..b752e09 --- /dev/null +++ b/adapters/discord/src/listeners/guild-member/guild-member.listener.ts @@ -0,0 +1,12 @@ +import { greetingsService } from "features/greeting/greetings.service"; +import client from "lib/client"; + +client.on("guildMemberAdd", async (member) => { + if (member.user.bot) { + // Don't send a welcome message for bots (sorry tom) + + return; + } + + greetingsService.sendGreeting(member.user, member.user.username); +}); diff --git a/adapters/discord/src/listeners/reactions/reactions.listener.ts b/adapters/discord/src/listeners/reactions/reactions.listener.ts new file mode 100644 index 0000000..acb9524 --- /dev/null +++ b/adapters/discord/src/listeners/reactions/reactions.listener.ts @@ -0,0 +1,20 @@ +import { Roles } from "@avocadi/bot-core/entities/roles/roles.schema"; +import { ReactionRolesService } from "features/reaction-roles/reaction-roles.service"; +import client from "lib/client"; + +const reactionRolesService = new ReactionRolesService(); + +// Currently only used for the "people" role, but can be extended to handle multiple roles based on the reaction emoji + +client.on("messageReactionAdd", async (reaction, user) => { + reactionRolesService.handleReaction(reaction, user, Roles.enum.people, "add"); +}); + +client.on("messageReactionRemove", async (reaction, user) => { + reactionRolesService.handleReaction( + reaction, + user, + Roles.enum.people, + "remove", + ); +}); diff --git a/adapters/discord/src/listeners/voice-state/voice-state.listener.ts b/adapters/discord/src/listeners/voice-state/voice-state.listener.ts new file mode 100644 index 0000000..07db4d8 --- /dev/null +++ b/adapters/discord/src/listeners/voice-state/voice-state.listener.ts @@ -0,0 +1,6 @@ +import { Events } from "discord.js"; +import client from "lib/client"; + +client.on(Events.VoiceStateUpdate, async (oldState, newState) => { + // TODO: handle updates +}); diff --git a/adapters/discord/tsconfig.json b/adapters/discord/tsconfig.json index bfa0fea..90419b4 100644 --- a/adapters/discord/tsconfig.json +++ b/adapters/discord/tsconfig.json @@ -1,29 +1,31 @@ { - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "baseUrl": "src", + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } } diff --git a/adapters/discord/tsdown.config.ts b/adapters/discord/tsdown.config.ts new file mode 100644 index 0000000..397fa75 --- /dev/null +++ b/adapters/discord/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + format: "esm", + dts: true, + exports: true, + fixedExtension: false, +}); diff --git a/bun.lock b/bun.lock index 65b3828..903f13c 100644 --- a/bun.lock +++ b/bun.lock @@ -11,10 +11,16 @@ "name": "@avocadi/bot-adapter-discord", "dependencies": { "@avocadi/bot-core": "workspace:*", + "@discordjs/rest": "^2.6.0", + "cron": "^4.4.0", + "discord.js": "^14.25.1", + "dotenv": "^17.3.1", + "dotenv-expand": "^12.0.3", "zod": "catalog:", }, "devDependencies": { "@types/bun": "latest", + "tsdown": "catalog:", }, "peerDependencies": { "typescript": "^5", @@ -37,6 +43,7 @@ "discord.js": "^14.16.3", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", + "tslog": "^4.10.2", "zod": "catalog:", }, "devDependencies": { @@ -221,7 +228,7 @@ "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - "@types/luxon": ["@types/luxon@3.4.2", "", {}, "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="], + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -253,7 +260,7 @@ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - "cron": ["cron@3.5.0", "", { "dependencies": { "@types/luxon": "~3.4.0", "luxon": "~3.5.0" } }, "sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A=="], + "cron": ["cron@4.4.0", "", { "dependencies": { "@types/luxon": "~3.7.0", "luxon": "~3.7.0" } }, "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -269,7 +276,9 @@ "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], - "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="], @@ -323,7 +332,7 @@ "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], - "luxon": ["luxon@3.5.0", "", {}, "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="], @@ -397,6 +406,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tslog": ["tslog@4.10.2", "", {}, "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -419,12 +430,22 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@avocadi/bot-core/cron": ["cron@3.5.0", "", { "dependencies": { "@types/luxon": "~3.4.0", "luxon": "~3.5.0" } }, "sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A=="], + + "@avocadi/bot-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "discord.js/@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], "discord.js/@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "@avocadi/bot-core/cron/@types/luxon": ["@types/luxon@3.4.2", "", {}, "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="], + + "@avocadi/bot-core/cron/luxon": ["luxon@3.5.0", "", {}, "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/core/package.json b/core/package.json index 55e18c3..f5e705c 100644 --- a/core/package.json +++ b/core/package.json @@ -21,6 +21,7 @@ "discord.js": "^14.16.3", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", + "tslog": "^4.10.2", "zod": "catalog:" }, "trustedDependencies": [ @@ -32,8 +33,17 @@ "./db": "./dist/db/index.js", "./db/schema": "./dist/db/schema.js", "./entities/channels/channels.schema": "./dist/entities/channels/channels.schema.js", + "./entities/commands/commands.entity": "./dist/entities/commands/commands.entity.js", + "./entities/commands/commands.schema": "./dist/entities/commands/commands.schema.js", + "./entities/interactions/interactions.schema": "./dist/entities/interactions/interactions.schema.js", + "./entities/messages/messages.service": "./dist/entities/messages/messages.service.js", "./entities/roles/roles.schema": "./dist/entities/roles/roles.schema.js", - "./lib/client": "./dist/lib/client.js", + "./entities/roles/roles.service": "./dist/entities/roles/roles.service.js", + "./features/greeting/greeting.service": "./dist/features/greeting/greeting.service.js", + "./features/text-based-feature/text-based-feature": "./dist/features/text-based-feature/text-based-feature.js", + "./features/text-based-feature/text-based-feature.schema": "./dist/features/text-based-feature/text-based-feature.schema.js", + "./features/water-me/water-me.service": "./dist/features/water-me/water-me.service.js", + "./lib/logger": "./dist/lib/logger.js", "./lib/utils": "./dist/lib/utils.js", "./lib/utils.test": "./dist/lib/utils.test.js", "./package.json": "./package.json" diff --git a/core/src/actions/dm/dm.service.ts b/core/src/actions/dm/dm.service.ts index 1cc7c96..5c08313 100644 --- a/core/src/actions/dm/dm.service.ts +++ b/core/src/actions/dm/dm.service.ts @@ -1,104 +1,110 @@ import config from "config"; +import { + type CacheType, + Client, + EmbedBuilder, + type GuildMember, + type Interaction, + type Message, + type OmitPartialGroupDMChannel, +} from "discord.js"; import client from "lib/client"; import { getRandomInt } from "lib/utils"; -import { dmWelcomeContent, dmAcceptedContent } from "./dm.components.ts"; -import { - Client, - EmbedBuilder, - type Message, - type CacheType, - type GuildMember, - type Interaction, - type OmitPartialGroupDMChannel, -} from "discord.js"; +import { dmAcceptedContent, dmWelcomeContent } from "./dm.components.ts"; export class DmService { + async handleInteraction(interaction: Interaction) { + // todo + } - async handleInteraction(interaction: Interaction) { - // todo - } + async reminderPrivate(member: GuildMember) { + console.log("reminder"); + try { + const content = `hey, kleine erinnerung :)\nbitte stelle dich auf <#${config.discord.channelIdIntroduction}> vor, damit du alle kanaele ansehen und nutzen kannst! <3`; + await client.users.send(member, content); + } catch (error) { + const channels = client.channels; + const channel = channels.cache.get(config.discord.channelIdLog); + if (channel?.isTextBased() && channel?.isSendable()) { + await channel.send( + `konnte keine private nachricht an <@${member.user.id}> senden`, + ); + } + console.error("error while sending a welcome msg:", error); + } + } - async reminderPrivate(member: GuildMember) { - console.log("reminder"); - try { - const content = `hey, kleine erinnerung :)\nbitte stelle dich auf <#${config.discord.channelIdIntroduction}> vor, damit du alle kanaele ansehen und nutzen kannst! <3` - await client.users.send(member, content); - } catch (error) { - const channels = client.channels; - const channel = channels.cache.get(config.discord.channelIdLog); - if (channel?.isTextBased() && channel?.isSendable()) { - await channel.send(`konnte keine private nachricht an <@${member.user.id}> senden`); - } - console.error("error while sending a welcome msg:", error); - } - } + async welcomePrivate(member: GuildMember) { + console.log("welcome private"); + try { + await client.users.send(member, dmWelcomeContent); + } catch (error) { + const channels = client.channels; + const channel = channels.cache.get(config.discord.channelIdLog); + if (channel?.isTextBased() && channel?.isSendable()) { + await channel.send( + `konnte keine private nachricht an <@${member.user.id}> senden`, + ); + } + console.error("error while sending a welcome msg:", error); + } + } - async welcomePrivate(member: GuildMember) { - console.log("welcome private"); - try { - await client.users.send(member, dmWelcomeContent); - } catch (error) { - const channels = client.channels; - const channel = channels.cache.get(config.discord.channelIdLog); - if (channel?.isTextBased() && channel?.isSendable()) { - await channel.send(`konnte keine private nachricht an <@${member.user.id}> senden`); - } - console.error("error while sending a welcome msg:", error); - } - } + async forward(message: OmitPartialGroupDMChannel>) { + if (message.channel.isDMBased()) { + const author = message.author.id; + const recipient = message.channel.recipient?.id; + console.log("forward message"); + let context = ""; - async forward(message: OmitPartialGroupDMChannel>) { - if (message.channel.isDMBased()) { - const author = message.author.id; - const recipient = message.channel.recipient?.id; - console.log("forward message"); - let context = ""; + if (message.author.bot) { + context = `<@${author}> hat an <@${recipient}> geschrieben:\n"${message.content}"`; + } else { + context = `<@${author}> hat geschrieben:\n"${message.content}"`; + } - if (message.author.bot) { - context = `<@${author}> hat an <@${recipient}> geschrieben:\n"${message.content}"`; - } - else { - context = `<@${author}> hat geschrieben:\n"${message.content}"`; - } + try { + const channels = client.channels; + const channel = channels.cache.get(config.discord.channelIdLog); + if (channel?.isTextBased() && channel?.isSendable()) { + await channel.send(context); + } + } catch (error) { + console.error("error while forwarding a msg:", error); + } + } + } - try { - const channels = client.channels; - const channel = channels.cache.get(config.discord.channelIdLog); - if (channel?.isTextBased() && channel?.isSendable()) { - await channel.send(context); - } - } catch (error) { - console.error("error while forwarding a msg:", error); - } - } - } + async acceptDm(member: GuildMember) { + console.log("accept dm"); + try { + await client.users.send(member, dmAcceptedContent); + } catch (error) { + const channels = client.channels; + const channel = channels.cache.get(config.discord.channelIdLog); + if (channel?.isTextBased() && channel?.isSendable()) { + await channel.send( + `konnte keine private nachricht an <@${member.user.id}> senden`, + ); + } + console.error("error while sending a accept msg:", error); + } + } - async acceptDm(member: GuildMember) { - console.log("accept dm"); - try { - await client.users.send(member, dmAcceptedContent); - } catch (error) { - const channels = client.channels; - const channel = channels.cache.get(config.discord.channelIdLog); - if (channel?.isTextBased() && channel?.isSendable()) { - await channel.send(`konnte keine private nachricht an <@${member.user.id}> senden`); - } - console.error("error while sending a accept msg:", error); - } - } - - async roleMentionDm(member: GuildMember, add: boolean) { - console.log("rolementionadd dm"); - try { - const contentRoleMentionDm = `du hast die rolle **streber:in** erfolgreich ** *${(add ? "zugeteilt" : "entfernt")}* ** bekommen :3 <#${config.discord.channelIdOffTopic}> <:avocadi_cute:1321893797138923602>`; - client.users.send(member, contentRoleMentionDm); - } catch (error) { - const channels = client.channels; - const channel = channels.cache.get(config.discord.channelIdLog); - if (channel?.isTextBased() && channel?.isSendable()) { - await channel.send(`konnte keine private nachricht an <@${member.user.id}> senden`); - } - console.error("error while sending a accept msg:", error); - } - } + async roleMentionDm(member: GuildMember, add: boolean) { + console.log("rolementionadd dm"); + try { + const contentRoleMentionDm = `du hast die rolle **streber:in** erfolgreich ** *${add ? "zugeteilt" : "entfernt"}* ** bekommen :3 <#${config.discord.channelIdOffTopic}> <:avocadi_cute:1321893797138923602>`; + client.users.send(member, contentRoleMentionDm); + } catch (error) { + const channels = client.channels; + const channel = channels.cache.get(config.discord.channelIdLog); + if (channel?.isTextBased() && channel?.isSendable()) { + await channel.send( + `konnte keine private nachricht an <@${member.user.id}> senden`, + ); + } + console.error("error while sending a accept msg:", error); + } + } } diff --git a/core/src/actions/greeting/greeting.service.ts b/core/src/actions/greeting/greeting.service.ts index fe19a79..3eefcbc 100644 --- a/core/src/actions/greeting/greeting.service.ts +++ b/core/src/actions/greeting/greeting.service.ts @@ -1,24 +1,21 @@ +import { DmService } from "actions/dm/dm.service.ts"; +import { Commands, type CommandsType } from "commands/index.ts"; import config from "config"; +import { + type CacheType, + type ChatInputCommandInteraction, + type GuildMember, + GuildMemberRoleManager, + type Interaction, +} from "discord.js"; import client from "lib/client"; import { getRandomInt } from "lib/utils"; +import { checkPermission } from "permissions/index.ts"; import { getWelcomeContent, greetContent, sleepContent, } from "./greeting.components.ts"; -import { - type ChatInputCommandInteraction, - Client, - EmbedBuilder, - type CacheType, - type GuildMember, - type Interaction, - GuildMemberRoleManager, - type APIInteractionGuildMember, -} from "discord.js"; -import { DmService } from "actions/dm/dm.service.ts"; -import { Commands, type CommandsType } from "commands/index.ts"; -import { checkPermission } from "permissions/index.ts"; export class GreetingService { dmService: DmService; @@ -27,17 +24,15 @@ export class GreetingService { this.dmService = new DmService(); } - async handleInteraction( - interaction: Interaction - ) { - + async handleInteraction(interaction: Interaction) { if (interaction.isChatInputCommand()) { await this.handleChatInputCommand(interaction); return; } - } - async handleChatInputCommand(interaction: ChatInputCommandInteraction) { + async handleChatInputCommand( + interaction: ChatInputCommandInteraction, + ) { const commandName = interaction.commandName as CommandsType; switch (commandName) { case Commands.Enum.accept: @@ -72,9 +67,7 @@ export class GreetingService { } } - async acceptUser( - interaction: ChatInputCommandInteraction - ) { + async acceptUser(interaction: ChatInputCommandInteraction) { console.log("accept user"); // get the string option @@ -83,7 +76,7 @@ export class GreetingService { //console.log(input); // permission check - if (await checkPermission(interaction.member) !== true) { + if ((await checkPermission(interaction.member)) !== true) { await interaction.reply({ content: "du hast keine rechte fuer diesen befehl", ephemeral: true, @@ -108,7 +101,7 @@ export class GreetingService { const username = member.user.username; console.log(username); - if (await this.checkRole(member) === true) { + if ((await this.checkRole(member)) === true) { await interaction.reply({ content: `${member.user.username} hat die rolle *lernende:r* schon!`, ephemeral: true, @@ -192,8 +185,7 @@ export class GreetingService { return greetContent[getRandomInt(0, greetContent.length - 1)]; } - async reminderCommand( - interaction: ChatInputCommandInteraction) { + async reminderCommand(interaction: ChatInputCommandInteraction) { console.log("remind user"); // get the string option @@ -203,7 +195,7 @@ export class GreetingService { try { // permission check - if (await checkPermission(interaction.member) !== true) { + if ((await checkPermission(interaction.member)) !== true) { await interaction.reply({ content: "du hast keine rechte fuer diesen befehl", ephemeral: true, @@ -227,7 +219,7 @@ export class GreetingService { const username = member.user.username; console.log(username); - if (await this.checkRole(member) === true) { + if ((await this.checkRole(member)) === true) { await interaction.reply({ content: `${member.user.username} hat die rolle *lernende:r* schon!`, ephemeral: true, @@ -252,9 +244,7 @@ export class GreetingService { } } - async welcomeCommand( - interaction: ChatInputCommandInteraction - ) { + async welcomeCommand(interaction: ChatInputCommandInteraction) { console.log("welcome user"); // get the string option @@ -275,7 +265,7 @@ export class GreetingService { return; } - if (await checkPermission(interaction.member) !== true) { + if ((await checkPermission(interaction.member)) !== true) { await interaction.reply({ content: "du hast keine rechte fuer diesen befehl", ephemeral: true, @@ -290,7 +280,7 @@ export class GreetingService { const welcomeContent = getWelcomeContent(member); - if (await this.checkRole(member) === true) { + if ((await this.checkRole(member)) === true) { await interaction.reply({ content: `${member.user.username} wurde schon begruesst!`, ephemeral: true, @@ -306,7 +296,6 @@ export class GreetingService { } await this.dmService.welcomePrivate(member); - } catch (error) { console.error("error while sending a welcome command msg:", error); } @@ -315,12 +304,10 @@ export class GreetingService { content: `erfolgreich welcome command: ${member.user.username}`, ephemeral: true, }); - } catch (error) { console.error("fehler bei welcome command", error); await interaction.reply({ - content: - "fehler bei welcome command", + content: "fehler bei welcome command", ephemeral: true, }); } @@ -329,7 +316,10 @@ export class GreetingService { async checkRole(member: GuildMember) { let hasRole = false; if (member?.roles instanceof GuildMemberRoleManager) { - if (member.roles.cache.has(config.discord.roleAdmin) || member.roles.cache.has(config.discord.roleMod)) { + if ( + member.roles.cache.has(config.discord.roleAdmin) || + member.roles.cache.has(config.discord.roleMod) + ) { console.log("user has permission"); hasRole = true; } diff --git a/core/src/actions/waterMe/waterMe.service.ts b/core/src/actions/waterMe/waterMe.service.ts deleted file mode 100644 index f12c5b7..0000000 --- a/core/src/actions/waterMe/waterMe.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { CronJob } from "cron"; -import { getRandomInt } from "lib/utils"; -import config from "config"; -import client from "lib/client"; -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - type CacheType, - type Interaction, -} from "discord.js"; - -export class WaterMeService { - waterLevel: number; - - private thirsty = 3 as const; - private enough = 10 as const; - - constructor() { - this.waterLevel = 0; - } - - getReply() { - const thirstyReplies = [ - "... wow das wars schon??? ich brauche noch mehr wasser :(", - "dankeeeee!!!! ich waer fast verdurstet :(((", - "*roelpssssss*", - ]; - - const fullReplies = [ - "langsam reicht es :o", - "poah, das hat gut getan", - "das ist krass :3", - ]; - - const tooMuchReplies = [ - "ES REICHT!!!!", - "bitte hoer auf, ich platze gleich :(", - ]; - - if (this.waterLevel <= this.thirsty) { - return thirstyReplies[getRandomInt(0, thirstyReplies.length - 1)]; - } - if (this.waterLevel > this.thirsty && this.waterLevel <= this.enough) { - return fullReplies[getRandomInt(0, fullReplies.length - 1)]; - } - if (this.waterLevel > this.enough) { - return tooMuchReplies[getRandomInt(0, tooMuchReplies.length - 1)]; - } - } - - async isThirsty() { - const channels = client.channels; - - const channel = channels.cache.get(config.discord.channelIdBot); - - if ( - channel?.isTextBased && - channel?.isSendable() && - this.waterLevel <= this.thirsty - ) { - await channel.send({ content: "ich brauche wasser :(" }); - } - } - - waterMe() { - const reply = this.getReply(); - - this.waterLevel++; - console.log(this.waterLevel); - - return { - reply, - }; - } - - async handleInteraction(interaction: Interaction) { - const result = this.waterMe(); - - const moreButton = new ButtonBuilder() - .setCustomId("moreWater") - .setLabel("mehr") - .setStyle(ButtonStyle.Secondary); - - const row = new ActionRowBuilder().addComponents(moreButton); - - if (interaction.isChatInputCommand()) { - await interaction.reply({ - content: result.reply, - // biome-ignore lint/suspicious/noExplicitAny: - components: [row as any], - }); - } else if (interaction.isButton()) { - await interaction.reply({ - content: result.reply, - }); - } - } -} diff --git a/core/src/actions/waterMe/waterMe.task.ts b/core/src/actions/waterMe/waterMe.task.ts deleted file mode 100644 index 9738540..0000000 --- a/core/src/actions/waterMe/waterMe.task.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CronJob } from "cron"; -import { WaterMeService } from "actions/waterMe/waterMe.service"; - -const waterMeService = new WaterMeService(); -/* -new CronJob( - "0 0 20 * * *", // cronTime - async () => { - console.log("isThirsty()"); - await waterMeService.isThirsty(); - }, // onTick - null, // onComplete - true, // start - "Europe/Berlin", // timeZone -);*/ -// job.start() is optional here because of the fourth parameter set to true. \ No newline at end of file diff --git a/core/src/commands/index.ts b/core/src/commands/index.ts deleted file mode 100644 index 7361265..0000000 --- a/core/src/commands/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { SlashCommandBuilder, userMention } from "discord.js"; -import { z } from "zod"; - -export const Commands = z.enum(["giessen", "medikamente", "hilfe", "support", "kofi", "disboard", "discadia", "accept", "welcome", "embed", "message", "reminder", "version"]); - -export const CommandsMeta: Record, { description: string }> = { - giessen: { - description: "giess mich mit etwas wasser :3" - }, - medikamente: { - description: "ich erinnere dich gerne daran, deine medikamente zu nehmen! :)" - }, - hilfe: { - description: "ich schreibe dir auf, was du alles mit mir machen kannst :)" - }, - support: { - description: "unterstuetze uns! link zu unserem ko-fi, disboard und discardia c:" - }, - kofi: { - description: "link zu unserem ko-fi (spendenportal):" - }, - disboard: { - description: "link zu disboard, hier kannst du uns bewerten!" - }, - discadia: { - description: "link zu discadia, hier kannst du fuer uns voten!" - }, - accept: { - description: "admin use only" - }, - welcome: { - description: "admin use only" - }, - embed: { - description: "admin use only" - }, - message: { - description: "admin use only" - }, - reminder: { - description: "admin use only" - }, - version: { - description: "admin use only" - } -} - -export type CommandsType = z.output; - -export default function getCommands() { - const commands = [ - new SlashCommandBuilder() - .setName(Commands.Enum.giessen) - .setDescription(CommandsMeta.giessen.description), - new SlashCommandBuilder() - .setName(Commands.Enum.medikamente) - .setDescription(CommandsMeta.medikamente.description), - new SlashCommandBuilder() - .setName(Commands.Enum.hilfe) - .setDescription(CommandsMeta.hilfe.description), - new SlashCommandBuilder() - .setName(Commands.Enum.support) - .setDescription(CommandsMeta.support.description), - new SlashCommandBuilder() - .setName(Commands.Enum.kofi) - .setDescription(CommandsMeta.kofi.description), - new SlashCommandBuilder() - .setName(Commands.Enum.disboard) - .setDescription(CommandsMeta.disboard.description), - new SlashCommandBuilder() - .setName(Commands.Enum.discadia) - .setDescription(CommandsMeta.discadia.description), - new SlashCommandBuilder() - .setName(Commands.Enum.accept) - .setDescription(CommandsMeta.accept.description) - .addStringOption(option => - option.setName('input') - .setDescription('input for bot') - .setRequired(true)), - new SlashCommandBuilder() - .setName(Commands.Enum.welcome) - .setDescription(CommandsMeta.welcome.description) - .addStringOption(option => - option.setName('input') - .setDescription('input for bot') - .setRequired(true)), - new SlashCommandBuilder() - .setName(Commands.Enum.embed) - .setDescription(CommandsMeta.embed.description) - .addStringOption(option => - option.setName('title') - .setDescription('title') - .setRequired(true)) - .addStringOption(option => - option.setName('description') - .setDescription('description') - .setRequired(true)) - .addBooleanOption(option => - option.setName('timestamp') - .setDescription('timestamp bool') - .setRequired(false)), - new SlashCommandBuilder() - .setName(Commands.Enum.message) - .setDescription(CommandsMeta.message.description) - .addStringOption(option => - option.setName('input') - .setDescription('input for bot') - .setRequired(true)), - new SlashCommandBuilder() - .setName(Commands.Enum.reminder) - .setDescription(CommandsMeta.reminder.description) - .addStringOption(option => - option.setName('input') - .setDescription('input for bot') - .setRequired(true)), - new SlashCommandBuilder() - .setName(Commands.Enum.version) - .setDescription(CommandsMeta.version.description), - - ].map((command) => command.toJSON()); - - return commands; -} diff --git a/core/src/discord.service.ts b/core/src/discord.service.ts deleted file mode 100644 index fa94f6f..0000000 --- a/core/src/discord.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Routes } from "discord.js"; -import { REST } from "@discordjs/rest"; -import config from "config"; -import getCommands from "commands"; - -export default class DiscordService { - rest: REST; - - constructor() { - this.rest = new REST({ version: "10" }).setToken(config.discord.token); - } - - async init() { - try { - await this.rest.put( - Routes.applicationCommands(config.discord.applicationId), - { - body: getCommands(), - }, - ); - console.log("Successfully added commands"); - } catch (e) { - console.error(e); - } - } -} diff --git a/core/src/entities/commands/commands.entity.ts b/core/src/entities/commands/commands.entity.ts new file mode 100644 index 0000000..864b6f0 --- /dev/null +++ b/core/src/entities/commands/commands.entity.ts @@ -0,0 +1,84 @@ +import type z from "zod"; +import type { Commands } from "./commands.schema"; + +// TODO: add missing options +export const CommandsCollection: z.output = { + giessen: { + description: "giess mich mit etwas wasser :3", + }, + medikamente: { + description: + "ich erinnere dich gerne daran, deine medikamente zu nehmen! :)", + }, + hilfe: { + description: "ich schreibe dir auf, was du alles mit mir machen kannst :)", + }, + support: { + description: + "unterstuetze uns! link zu unserem ko-fi, disboard und discardia c:", + }, + kofi: { + description: "link zu unserem ko-fi (spendenportal):", + }, + disboard: { + description: "link zu disboard, hier kannst du uns bewerten!", + }, + discadia: { + description: "link zu discadia, hier kannst du fuer uns voten!", + }, + accept: { + description: "admin use only", + options: [ + { + name: "input", + description: "input for bot", + required: true, + type: "string", + }, + ], + }, + welcome: { + description: "admin use only", + options: [ + { + name: "input", + description: "input for bot", + required: true, + type: "string", + }, + ], + }, + embed: { + description: "admin use only", + options: [ + { + name: "title", + description: "title", + required: true, + type: "string", + }, + { + name: "description", + description: "description", + required: true, + type: "string", + }, + { + name: "timestamp", + description: "timestamp bool", + required: false, + type: "boolean", + }, + ], + }, + + message: { + description: "admin use only", + }, + reminder: { + description: "admin use only", + }, + version: { + description: "admin use only", + }, +}; diff --git a/core/src/entities/commands/commands.schema.ts b/core/src/entities/commands/commands.schema.ts new file mode 100644 index 0000000..d21b49d --- /dev/null +++ b/core/src/entities/commands/commands.schema.ts @@ -0,0 +1,59 @@ +import z from "zod"; + +export const CommandKeyOptions = [ + "giessen", + "medikamente", + "hilfe", + "support", + "kofi", + "disboard", + "discadia", + "accept", + "welcome", + "embed", + "message", + "reminder", + "version", +] as const; + +export const CommandOptionTypeOptions = [ + "string", + "integer", + "boolean", + "mentionable", + "channel", + "role", + "user", +] as const; + +export const CommandOptionTypes = z.enum(CommandOptionTypeOptions); + +export const CommandOptionCommon = z.object({ + name: z.string(), + description: z.string(), + required: z.boolean(), +}); + +export const CommandOptionString = CommandOptionCommon.extend({ + type: z.literal(CommandOptionTypes.enum.string), +}); + +export const CommandOptionBoolean = CommandOptionCommon.extend({ + type: z.literal(CommandOptionTypes.enum.boolean), +}); + +// TODO: add other option types +export const CommandOption = z.discriminatedUnion("type", [ + CommandOptionString, + CommandOptionBoolean, +]); + +export const CommandKeys = z.enum(CommandKeyOptions); + +export const Command = z.object({ + name: z.string().optional(), + description: z.string(), + options: z.array(CommandOption).optional(), +}); + +export const Commands = z.record(CommandKeys, Command); diff --git a/core/src/entities/interactions/interactions.schema.ts b/core/src/entities/interactions/interactions.schema.ts new file mode 100644 index 0000000..cc006ef --- /dev/null +++ b/core/src/entities/interactions/interactions.schema.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const InteractionOptions = ["command", "button"] as const; + +export const Interactions = z.enum(InteractionOptions); diff --git a/core/src/entities/messages/messages.service.ts b/core/src/entities/messages/messages.service.ts new file mode 100644 index 0000000..248006a --- /dev/null +++ b/core/src/entities/messages/messages.service.ts @@ -0,0 +1,3 @@ +export interface MessagesServiceInterface { + sendToUser(user: U, message: string): Promise; +} diff --git a/core/src/entities/roles/roles.service.ts b/core/src/entities/roles/roles.service.ts new file mode 100644 index 0000000..1070580 --- /dev/null +++ b/core/src/entities/roles/roles.service.ts @@ -0,0 +1,6 @@ +export interface RolesServiceInterface { + assignRole(user: U, role: string): void | Promise; + removeRole(user: U, role: string): void | Promise; + getRoles(user: U): string[] | Promise; + hasRole(user: U, role: string): boolean | Promise; +} diff --git a/core/src/features/greeting/greeting.service.ts b/core/src/features/greeting/greeting.service.ts new file mode 100644 index 0000000..54346af --- /dev/null +++ b/core/src/features/greeting/greeting.service.ts @@ -0,0 +1,14 @@ +import type { MessagesServiceInterface } from "entities/messages/messages.service"; + +export class GreetingService { + messagesService: MessagesServiceInterface; + + constructor(messagesService: MessagesServiceInterface) { + this.messagesService = messagesService; + } + + async sendGreeting(user: U, userName: string) { + const greetingMessage = `Hello, ${userName}! Welcome to the server!`; + await this.messagesService.sendToUser(user, greetingMessage); + } +} diff --git a/core/src/features/text-based-feature/text-based-feature.schema.ts b/core/src/features/text-based-feature/text-based-feature.schema.ts new file mode 100644 index 0000000..93f1add --- /dev/null +++ b/core/src/features/text-based-feature/text-based-feature.schema.ts @@ -0,0 +1,9 @@ +export type TextBasedFeatureHandleMessageSend = ( + message: string, + channelId: string, +) => void | Promise; + +export type TextBasedFeatureInput = { + channelId: string; + handleMessageSend: TextBasedFeatureHandleMessageSend; +}; diff --git a/core/src/features/text-based-feature/text-based-feature.ts b/core/src/features/text-based-feature/text-based-feature.ts new file mode 100644 index 0000000..a3e1f92 --- /dev/null +++ b/core/src/features/text-based-feature/text-based-feature.ts @@ -0,0 +1,18 @@ +import type { + TextBasedFeatureHandleMessageSend, + TextBasedFeatureInput, +} from "./text-based-feature.schema"; + +export class TextBasedFeature { + channelId: string; + handleMessageSend: TextBasedFeatureHandleMessageSend; + + constructor(input: TextBasedFeatureInput) { + this.channelId = input.channelId; + this.handleMessageSend = input.handleMessageSend; + } + + async sendMessage(input: { content: string }) { + this.handleMessageSend(input.content, this.channelId); + } +} diff --git a/core/src/features/water-me/water-me.service.ts b/core/src/features/water-me/water-me.service.ts new file mode 100644 index 0000000..8276ede --- /dev/null +++ b/core/src/features/water-me/water-me.service.ts @@ -0,0 +1,64 @@ +import { TextBasedFeature } from "features/text-based-feature/text-based-feature"; +import type { TextBasedFeatureInput } from "features/text-based-feature/text-based-feature.schema"; +import { createLogger } from "lib/logger"; +import { getRandomInt } from "lib/utils"; + +export class WaterMeService extends TextBasedFeature { + waterLevel: number; + + private logger = createLogger("WaterMeService"); + + private thirsty = 3 as const; + private enough = 10 as const; + + constructor(input: TextBasedFeatureInput) { + super(input); + this.waterLevel = 0; + } + + getReply() { + const thirstyReplies = [ + "... wow das wars schon??? ich brauche noch mehr wasser :(", + "dankeeeee!!!! ich waer fast verdurstet :(((", + "*roelpssssss*", + ]; + + const fullReplies = [ + "langsam reicht es :o", + "poah, das hat gut getan", + "das ist krass :3", + ]; + + const tooMuchReplies = [ + "ES REICHT!!!!", + "bitte hoer auf, ich platze gleich :(", + ]; + + if (this.waterLevel <= this.thirsty) { + return thirstyReplies[getRandomInt(0, thirstyReplies.length - 1)]; + } + if (this.waterLevel > this.thirsty && this.waterLevel <= this.enough) { + return fullReplies[getRandomInt(0, fullReplies.length - 1)]; + } + if (this.waterLevel > this.enough) { + return tooMuchReplies[getRandomInt(0, tooMuchReplies.length - 1)]; + } + } + + async notifyIfThirsty() { + if (this.waterLevel <= this.thirsty) { + await this.sendMessage({ content: "ich brauche wasser :(" }); + } + } + + waterMe() { + const reply = this.getReply(); + + this.waterLevel++; + this.logger.info(`Water level increased to ${this.waterLevel}`); + + return { + reply, + }; + } +} diff --git a/core/src/lib/client.ts b/core/src/lib/client.ts deleted file mode 100644 index 0af69a9..0000000 --- a/core/src/lib/client.ts +++ /dev/null @@ -1,22 +0,0 @@ -import config from "config"; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, IntentsBitField } from "discord.js"; - -const client = new Client({ - intents: [IntentsBitField.Flags.Guilds, - IntentsBitField.Flags.GuildMembers, - IntentsBitField.Flags.GuildModeration, - IntentsBitField.Flags.GuildMessages, - IntentsBitField.Flags.GuildMessageReactions, - IntentsBitField.Flags.GuildMessagePolls, - IntentsBitField.Flags.GuildVoiceStates, - IntentsBitField.Flags.MessageContent, - IntentsBitField.Flags.DirectMessages, - IntentsBitField.Flags.DirectMessageReactions, - IntentsBitField.Flags.DirectMessageTyping, - IntentsBitField.Flags.DirectMessagePolls,], - partials: [Partials.Channel, Partials.Message, Partials.Reaction] -}); - -await client.login(config.discord.token); - -export default client; diff --git a/core/src/lib/logger.ts b/core/src/lib/logger.ts new file mode 100644 index 0000000..4c85aa7 --- /dev/null +++ b/core/src/lib/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from "tslog"; + +export const createLogger = (name: string) => + new Logger({ + name, + prettyLogTemplate: + "{{yyyy}}.{{mm}}.{{dd}} {{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t{{name}}\t", + }); diff --git a/core/tsdown.config.ts b/core/tsdown.config.ts index 6d87d2a..6072e01 100644 --- a/core/tsdown.config.ts +++ b/core/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "./src/index.ts", "./src/entities/**/*.ts", + "./src/features/**/*.ts", "./src/lib/**/*.ts", "./src/api/**/*.ts", "./src/db/**/*.ts",