begin rewriting first services to be domain agnostic

more rewrites
This commit is contained in:
2026-02-17 22:42:36 +01:00
parent b3766b9584
commit 071fe2f891
45 changed files with 915 additions and 521 deletions

View File

@@ -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

View File

@@ -1 +0,0 @@
console.log("Hello via Bun!");

View File

@@ -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"
}
}

View File

@@ -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);
}
};

View File

@@ -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({

View File

@@ -0,0 +1,6 @@
import z from "zod";
export const EnvSchema = z.object({
DISCORD_APPLICATION_ID: z.string(),
DISCORD_TOKEN: z.string(),
});

View File

@@ -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<string, string> };
const env = EnvSchema.parse(expandedEnv.processEnv);
export default env;

View File

@@ -1,5 +1,6 @@
import type z from "zod";
import type { ConfigSchema } from "./config.schema";
import env from "./env";
export const config: z.output<typeof ConfigSchema> = {
channelMapping: {
@@ -34,10 +35,15 @@ export const config: z.output<typeof ConfigSchema> = {
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,
},
};

View File

@@ -0,0 +1,245 @@
import EventEmitter from "node:events";
import {
type WaterMeController,
waterMeController,
} from "features/water-me/water-me.controller";
import client from "lib/client";
export default class DiscordController extends EventEmitter {
private waterMeController: WaterMeController = waterMeController;
constructor() {
super();
let channelListeners = new Map();
// log when running
client.once("ready", async () => {
const channels = client.channels;
const logChannel = channels.cache.get(config.discord.channelIdLog);
if (logChannel?.isTextBased() && logChannel?.isSendable()) {
try {
console.log("bot is online");
await logChannel.send("wieder online!!!");
} catch (error) {
console.error("failed to send online message:", error);
}
} else {
console.error("log channel is not valid or sendable.");
}
await this.setActivity(100);
console.log("ready");
});
process.on("exit", async () => {
const channels = client.channels;
const logChannel = channels.cache.get(config.discord.channelIdLog);
await this.handleShutdown(logChannel);
process.exit(0);
});
process.on("SIGINT", async () => {
const channels = client.channels;
const logChannel = channels.cache.get(config.discord.channelIdLog);
await this.handleShutdown(logChannel);
process.exit(0);
});
process.on("SIGTERM", async () => {
const channels = client.channels;
const logChannel = channels.cache.get(config.discord.channelIdLog);
await this.handleShutdown(logChannel);
process.exit(0);
});
// listen for interactions
client.on("interactionCreate", this.handleInteraction.bind(this));
client.on("messageCreate", async (message) => {
console.log(message.id);
if (message.channel.type === ChannelType.DM) {
console.log("got msg");
await this.dmService.forward(message);
}
});
client.on("guildMemberAdd", async (member) => {
await this.greetingService.welcome(member);
});
client.on(
Events.VoiceStateUpdate,
async (oldState: VoiceState, newState: VoiceState) => {
// check if user joined a vc
if (
(!oldState.channelId && newState.channelId) ||
oldState.channelId !== newState.channelId
) {
// check if right vc
if (
newState.channelId === config.discord.vchannelIdForTwo ||
newState.channelId === config.discord.vchannelIdForThree ||
newState.channelId === config.discord.vchannelIdForFour ||
newState.channelId === config.discord.vchannelIdForGroup
) {
const channel = newState.channel;
if (!channel) {
console.error("channel not found");
return;
}
try {
const newChannel =
await this.dynamicVChannelService.createVChannel(
newState,
channel,
);
// move user in new channel
await newState.setChannel(newChannel);
// create specific listener for channel
const channelListener = async (
oldState: VoiceState,
newState: VoiceState,
) => {
channelListeners =
await this.dynamicVChannelService.deleteVChannel(
oldState,
newState,
newChannel,
channelListeners,
channelListener,
);
};
// save listener in map
channelListeners.set(newChannel.id, channelListener);
// add listener
client.on(Events.VoiceStateUpdate, channelListener);
} catch (error) {
console.error("error while duplicating channel", error);
}
}
}
},
);
console.log(
`----------------\nversion ${config.discord.version}\n----------------`,
);
}
async setActivity(state: number) {
switch (state) {
case 0:
client.user?.setActivity(" ", { type: 0 });
console.log("set activity");
client.user?.setPresence({
status: "invisible",
});
break;
default:
client.user?.setActivity("spielt sudoku", { type: 0 });
console.log("set activity");
client.user?.setPresence({
status: "online",
});
break;
}
}
async handleShutdown(logChannel: Channel | undefined) {
if (logChannel?.isTextBased() && logChannel?.isSendable()) {
try {
await logChannel.send("bot is going offline...");
} catch (error) {
console.error("failed to send offline message:", error);
}
} else {
console.error("log channel is not valid or sendable.");
}
await this.setActivity(0);
console.log("bot is offline.");
}
async init() {
await this.discordService.init();
}
async handleInteraction(interaction: Interaction<CacheType>) {
if (interaction.isModalSubmit()) {
await this.handleModalSubmit(interaction);
return;
}
if (interaction.isChatInputCommand()) {
await this.handleChatInputCommand(interaction);
return;
}
if (interaction.isButton()) {
await this.handleButton(interaction);
return;
}
}
async handleButton(interaction: ButtonInteraction<CacheType>) {
const { customId } = interaction;
console.log(interaction.customId);
if (customId.toLowerCase().includes("moreWater")) {
await this.waterMeService.handleInteraction(interaction);
}
if (customId.toLowerCase().includes("medication")) {
await this.medicationService.handleInteraction(interaction);
}
}
async handleChatInputCommand(
interaction: ChatInputCommandInteraction<CacheType>,
) {
const commandName = interaction.commandName as CommandsType;
// add commands
switch (commandName) {
case Commands.Enum.giessen:
await this.waterMeService.handleInteraction(interaction); // zu chatinputcommand wechseln
return;
case Commands.Enum.medikamente:
await this.medicationService.handleChatInputCommand(interaction);
return;
case Commands.Enum.hilfe:
await this.helpService.handleInteraction(interaction); // zu chatinputcommand wechseln
return;
case Commands.Enum.support:
case Commands.Enum.kofi:
case Commands.Enum.disboard:
case Commands.Enum.discadia:
await this.supportService.handleInteraction(interaction);
return;
case Commands.Enum.accept:
await this.greetingService.handleChatInputCommand(interaction);
return;
case Commands.Enum.welcome:
await this.greetingService.handleChatInputCommand(interaction);
return;
case Commands.Enum.embed:
case Commands.Enum.message:
await this.customMessageService.handleChatInputCommand(interaction);
return;
case Commands.Enum.reminder:
await this.greetingService.handleChatInputCommand(interaction);
return;
case Commands.Enum.version:
await this.debugService.handleChatInputCommand(interaction);
return;
default:
break;
}
}
// wenn neues fenster durch buttonclick or so
async handleModalSubmit(interaction: ModalSubmitInteraction<CacheType>) {
const { customId } = interaction;
switch (customId) {
default:
break;
}
}
}

View File

@@ -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<typeof Command>,
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),
);
}

View File

@@ -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<User> {
private logger = createLogger("MessagesService");
async sendToUser(userInput: User, message: string): Promise<void> {
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();

View File

@@ -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<GuildMember> {
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);
}
}

View File

@@ -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);

View File

@@ -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<typeof Roles>,
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<typeof Roles>,
) {
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<typeof Roles>,
) {
this.logger.info(
`Removing role ${role} based on reaction ${reaction.emoji.name} by user ${member.user.tag}`,
);
await member.roles.remove(config.roleMapping[role]);
}
}

View File

@@ -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<CacheType>) {
const result = this.waterMeService.waterMe();
const moreButton = new ButtonBuilder()
.setCustomId("moreWater")
.setLabel("mehr")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>().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();

View File

@@ -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}`);
},
});

View File

@@ -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
);

View File

@@ -0,0 +1,4 @@
import { publishCommands } from "actions/publish-commands";
// Publish commands when the application starts
await publishCommands();

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
import { REST } from "@discordjs/rest";
import { config } from "config";
export const discordRestClient = new REST({ version: "10" }).setToken(
config.discord.token,
);

View File

@@ -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);
});

View File

@@ -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",
);
});

View File

@@ -0,0 +1,6 @@
import { Events } from "discord.js";
import client from "lib/client";
client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
// TODO: handle updates
});

View File

@@ -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
}
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["./src/index.ts"],
format: "esm",
dts: true,
exports: true,
fixedExtension: false,
});