diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/Biomefile b/Biomefile new file mode 100644 index 0000000..2853af7 --- /dev/null +++ b/Biomefile @@ -0,0 +1,3 @@ +{ + "name": "avocadi-bot" +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..95e0258 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..09391f7 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "avocadi-bot", + "module": "src/index.ts", + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@discordjs/rest": "^2.4.0", + "cron": "^3.3.1", + "discord.js": "^14.16.3", + "zod": "^3.24.1" + } +} \ No newline at end of file diff --git a/src/components/commands.component.ts b/src/components/commands.component.ts new file mode 100644 index 0000000..e7f16c7 --- /dev/null +++ b/src/components/commands.component.ts @@ -0,0 +1,15 @@ +import { SlashCommandBuilder } from "discord.js"; +import { z } from "zod"; + +export const Commands = z.enum(["water-me"]); +export type CommandsType = z.output; + +export default function getCommands() { + const commands = [ + new SlashCommandBuilder() + .setName(Commands.Enum["water-me"]) + .setDescription("giess mich mit etwas wasser :3"), + ].map((command) => command.toJSON()); + + return commands; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0ecdb35 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,12 @@ +const test = true; + +export default { + discord: { + channelId: + (test + ? process.env.DISCORD_TEST_CHANNEL_ID + : process.env.DISCORD_CHANNEL_ID) || "", + applicationId: process.env.DISCORD_APPLICATION_ID || "", + token: process.env.DISCORD_TOKEN || "", + }, +}; diff --git a/src/controllers/discord.controller.ts b/src/controllers/discord.controller.ts new file mode 100644 index 0000000..3008995 --- /dev/null +++ b/src/controllers/discord.controller.ts @@ -0,0 +1,83 @@ +import { Commands, type CommandsType } from "components/commands.component"; +import type { + ButtonInteraction, + CacheType, + ChatInputCommandInteraction, + Interaction, + ModalSubmitInteraction, +} from "discord.js"; +import client from "lib/client"; +import EventEmitter from "node:events"; +import DiscordService from "services/discord.service"; +import { WaterMeService } from "services/water-me.service"; + +export default class DiscordController extends EventEmitter { + private discordService!: DiscordService; + waterMeService: WaterMeService; + + constructor() { + super(); + this.discordService = new DiscordService(); + this.waterMeService = new WaterMeService(); + // log when running + client.once("ready", () => { + console.log("Listening..."); + }); + // listen for interactions + client.on("interactionCreate", this.handleInteraction.bind(this)); + } + + async init() { + await this.discordService.init(); + } + + async handleInteraction(interaction: Interaction) { + 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) { + const { customId } = interaction; + console.log(interaction.customId); + + if (customId === "moreWater") { + await this.waterMeService.handleInteraction(interaction); + } + } + + async handleChatInputCommand( + interaction: ChatInputCommandInteraction, + ) { + const commandName = interaction.commandName as CommandsType; + + // add commands + switch (commandName) { + case Commands.Enum["water-me"]: + await this.waterMeService.handleInteraction(interaction); + return; + default: + break; + } + } + + // wenn neues fenster durch buttonclick or so + async handleModalSubmit(interaction: ModalSubmitInteraction) { + const { customId } = interaction; + + switch (customId) { + default: + break; + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..059bbf8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +import "tasks/water-me.task"; +import "tasks/greeting.task"; +import "tasks/drink.task"; +import DiscordController from "controllers/discord.controller"; + +// = main file + +// bootstrap application + +const discordController = new DiscordController(); +discordController.init(); diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..e50912b --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,8 @@ +import config from "config"; +import { Client, IntentsBitField } from "discord.js"; + +const client = new Client({ intents: [IntentsBitField.Flags.Guilds] }); + +await client.login(config.discord.token); + +export default client; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2d37ba2 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,5 @@ +export function getRandomInt(min: number, max: number) { + const nextMin = Math.ceil(min); + const nextMax = Math.floor(max); + return Math.floor(Math.random() * (nextMax - nextMin + 1)) + nextMin; +} diff --git a/src/services/discord.service.ts b/src/services/discord.service.ts new file mode 100644 index 0000000..bff3fe7 --- /dev/null +++ b/src/services/discord.service.ts @@ -0,0 +1,26 @@ +import { Routes } from "discord.js"; +import { REST } from "@discordjs/rest"; +import config from "config"; +import getCommands from "components/commands.component"; + +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/src/services/drink.service.ts b/src/services/drink.service.ts new file mode 100644 index 0000000..519eb4c --- /dev/null +++ b/src/services/drink.service.ts @@ -0,0 +1,14 @@ +import config from "config"; +import client from "lib/client"; + +export class GreetingService { + async greet() { + const channels = client.channels; + + const channel = channels.cache.get(config.discord.channelId); + + if (channel?.isTextBased && channel?.isSendable()) { + await channel.send({ content: "HALLOOOO" }); + } + } +} diff --git a/src/services/greeting.service.ts b/src/services/greeting.service.ts new file mode 100644 index 0000000..519eb4c --- /dev/null +++ b/src/services/greeting.service.ts @@ -0,0 +1,14 @@ +import config from "config"; +import client from "lib/client"; + +export class GreetingService { + async greet() { + const channels = client.channels; + + const channel = channels.cache.get(config.discord.channelId); + + if (channel?.isTextBased && channel?.isSendable()) { + await channel.send({ content: "HALLOOOO" }); + } + } +} diff --git a/src/services/water-me.service.ts b/src/services/water-me.service.ts new file mode 100644 index 0000000..758c928 --- /dev/null +++ b/src/services/water-me.service.ts @@ -0,0 +1,98 @@ +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???", + "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.channelId); + + if ( + channel?.isTextBased && + channel?.isSendable() && + this.waterLevel <= this.thirsty + ) { + await channel.send({ content: "ich brauche wasser :(" }); + } + } + + waterMe() { + const reply = this.getReply(); + + this.waterLevel++; + + return { + reply, + }; + } + + async handleInteraction(interaction: Interaction) { + const result = this.waterMe(); + + const moreButton = new ButtonBuilder() + .setCustomId("more") + .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/src/tasks/drink.task.ts b/src/tasks/drink.task.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/tasks/greeting.task.ts b/src/tasks/greeting.task.ts new file mode 100644 index 0000000..f57bd44 --- /dev/null +++ b/src/tasks/greeting.task.ts @@ -0,0 +1,16 @@ +import { CronJob } from "cron"; +import { GreetingService } from "services/greeting.service"; + +const greetingService = new GreetingService(); + +new CronJob( + "0 */1 * * * *", // cronTime + async () => { + console.log("called greeting"); + await greetingService.greet(); + }, // onTick + null, // onComplete + true, // start + "Europe/Berlin", // timeZone +); +// job.start() is optional here because of the fourth parameter set to true. diff --git a/src/tasks/water-me.task.ts b/src/tasks/water-me.task.ts new file mode 100644 index 0000000..2e22e94 --- /dev/null +++ b/src/tasks/water-me.task.ts @@ -0,0 +1,16 @@ +import { CronJob } from "cron"; +import { WaterMeService } from "services/water-me.service"; + +const waterMeService = new WaterMeService(); + +new CronJob( + "0 0 */2 * * *", // 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. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3753d85 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "baseUrl": "src", + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}