From a89150fc2f7a9782f34d99e3e52717987d9a622b Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 12 Sep 2024 14:37:08 +0000 Subject: [PATCH] Add features --- .dockerignore | 11 + .env.example | 6 + .gitignore | 6 +- Dockerfile | 19 ++ README.md | 70 +++++- docker-compose.yml | 5 + example.env | 5 - package-lock.json | 329 +++++++++++++++++++++++++++- package.json | 9 +- scripts/build.sh | 76 +++++++ scripts/register-slash-commands.sh | 3 + scripts/start.sh | 3 + src/commands/ai/image.js | 99 +++++++++ src/commands/db/game.js | 207 +++++++++++++++++ src/commands/lookup/imdb.js | 83 ++++--- src/commands/lookup/plant.js | 89 +++++--- src/commands/memes/eyecandy.js | 43 ++-- src/commands/utilities/countdown.js | 4 - src/index.js | 3 +- src/music/music.js | 132 +++++++++++ src/register-slash-commands.js | 5 +- src/utils/db/game.js | 37 ++++ 22 files changed, 1145 insertions(+), 99 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100644 example.env create mode 100755 scripts/build.sh create mode 100755 scripts/register-slash-commands.sh create mode 100755 scripts/start.sh create mode 100644 src/commands/ai/image.js create mode 100644 src/commands/db/game.js create mode 100644 src/music/music.js create mode 100644 src/utils/db/game.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69e2c85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules/ + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +Dockerfile +docker-compose.yml +docker-compose.override.yml \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..06d5e18 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_APPLICATION_ID= +DISCORD_API_KEY= +GIPHY_API_KEY= +PLANTNET_API_KEY= +OMDB_API_KEY= +REPLICATE_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index d1f77eb..dc1dbdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# ---> Build specific exclusions +docker-compose.override.yml + # ---> Windows # Windows thumbnail cache files Thumbs.db @@ -46,7 +49,8 @@ $RECYCLE.BIN/ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b368dde --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:lts-alpine + +# Create app directory and copy the app +WORKDIR /app +COPY . . + +# Run as non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /app +USER nodejs + +# Install +RUN npm install + +# Start the app +ENTRYPOINT ["npm", "run"] +CMD ["start"] + diff --git a/README.md b/README.md index b063a78..b48466e 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,76 @@ -# ButlerBotNG +# ButlerBot -## Installation +## Requirements + +ButlerBot is deployed using Docker. To run ButlerBot, you will need to have Docker installed on your machine. You can download Docker [here](https://www.docker.com/products/docker-desktop), or by using the following convenience script: ```bash -$ npm install +curl -fsSL https://get.docker.com | sh ``` ## Usage -1. Create `.env` (see `example.env`) and populate with all necessary details. -2. Register slash commands. +### Pre-built Docker Image + +The easiest way to install ButlerBot is to use the provided Docker image with the supplied `docker-compose.yml` file, found in the project directory. + +Edit the values in the `.env` file to match your environment. An example `.env` file is provided in the project directory as `.env.example`. + +Before ButlerBot can be used, you will need to register any new commands with the bot. To do this, run the following command: ```bash -$ npm run register-slash-commands +./scripts/register-slash-commands.sh ``` -3. Run ButlerBot. +To run ButlerBot using the latest release version, use the following command: ```bash -$ npm run start +./scripts/start.sh ``` +To run ButlerBot using another version, first update the `docker-compose.yml` file to use the appropriate image. Release and development versions of ButlerBot are available on 3t.network's package repository. + +| Version | Image | +| ----------- | -------------------------------------------- | +| Latest | `git.3t.network/3t.network/butlerbot:latest` | +| Development | `git.3t.network/3t.network/butlerbot:dev` | + +### Building the Docker Image + +Alternatively, you can build the Docker image from source. To install ButlerBot, clone the repository and navigate to the project directory. Then, build the Docker image: + +```bash +./scripts/build.sh +``` + +This will create a Docker image called `butlerbot`, tagged with the version number found in package.json. A `docker-compose.override.yml` file is created, which automatically overrides the project's `docker-compose.yml` file to use the newly built image. + ## Development -All commands are dynamically loaded at runtime, and are stored in `src/commands/[CATEGORY]/[NAME].js`. -To add a new command, just create a new .js file. It must export a `data` object and `execute` promise. See existing commands for an example. -Remember to re-register slash commands using `npm run register-slash-commands` if adding a new command, or modifying an existing command's parameters. +To run ButlerBot in development mode, you will need to have Node.js installed on your machine. You can download Node.js [here](https://nodejs.org/en/). ButlerBot is built using the latest Node.js LTS version. + +To install the project dependencies, run: + +```bash +npm install +``` + +To update the bot's commands, run: + +```bash +npm run register-slash-commands +``` + +To start ButlerBot, run: + +```bash +npm run start +``` + +N.B. ButlerBot requires several environment variables to run. These are stored in a `.env` file in the project directory. An example `.env` file is provided in the project directory as `.env.example`. + +## DevOps + +ButlerBot is deployed using Docker. The project includes a `Dockerfile` and `docker-compose.yml` file for building and running the bot. The `docker-compose.yml` file includes a `butlerbot` service, which runs the bot using the latest release version. + +When a new release is ready, the version number in the `package.json` file should be updated using `npm version patch`, and merged into the `main` branch. This will trigger a new release on 3t.network's package repository, which can be used to update the bot's Docker image via Watchtower. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8fd82b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +services: + butlerbot: + image: git.3t.network/3t.network/butlerbot:latest + env_file: + - .env diff --git a/example.env b/example.env deleted file mode 100644 index eafc879..0000000 --- a/example.env +++ /dev/null @@ -1,5 +0,0 @@ -DISCORD_TOKEN=MTI4MDQ0NDY1MjUyODQ2Nz... -DISCORD_APPLICATION_ID=12804446... -GIPHY_API_KEY=mg7MHuxn42R4TE33... -PLANTNET_API_KEY=2b10p4Zb7K... -OMDB_API_KEY=96e... \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8030084..a248f23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,11 @@ "axios": "^1.7.7", "date-fns": "^3.6.0", "discord.js": "^14.16.1", + "dotenv": "^16.4.5", "glob": "^11.0.0", - "prettier": "^3.3.3" + "mongodb": "^6.8.1", + "prettier": "^3.3.3", + "replicate": "^0.32.1" }, "devDependencies": { "eslint": "^9.9.1" @@ -322,6 +325,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -412,6 +424,21 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -431,6 +458,19 @@ "npm": ">=7.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -525,6 +565,27 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -536,6 +597,40 @@ "concat-map": "0.0.1" } }, + "node_modules/bson": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz", + "integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -690,6 +785,18 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -869,6 +976,26 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1083,6 +1210,27 @@ "node": ">=8" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1294,6 +1442,12 @@ "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==", "license": "MIT" }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1337,6 +1491,62 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mongodb": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz", + "integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.7.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -1480,6 +1690,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -1490,7 +1710,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1517,6 +1736,38 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "license": "MIT", + "optional": true, + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/replicate": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/replicate/-/replicate-0.32.1.tgz", + "integrity": "sha512-OmzyDVx3P9FXVXcFrzWft8XW8LjyGo65qRCwUPJwbjBZS4Z89pQFX+ZUfYlHwcfIHGtP4/Pf0uis+XJiznKacQ==", + "license": "Apache-2.0", + "engines": { + "git": ">=2.11.0", + "node": ">=18.0.0", + "npm": ">=7.19.0", + "yarn": ">=1.7.0" + }, + "optionalDependencies": { + "readable-stream": ">=4.0.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1562,6 +1813,27 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1595,6 +1867,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -1718,6 +2009,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -1768,6 +2071,28 @@ "punycode": "^2.1.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3e5777f..80e2971 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "main": "src/index.js", "type": "module", "scripts": { - "start": "node --env-file .env src/index.js", - "register-slash-commands": "node --env-file .env src/register-slash-commands.js", + "start": "node src/index.js", + "register-slash-commands": "node src/register-slash-commands.js", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { @@ -19,8 +19,11 @@ "axios": "^1.7.7", "date-fns": "^3.6.0", "discord.js": "^14.16.1", + "dotenv": "^16.4.5", "glob": "^11.0.0", - "prettier": "^3.3.3" + "mongodb": "^6.8.1", + "prettier": "^3.3.3", + "replicate": "^0.32.1" }, "devDependencies": { "eslint": "^9.9.1" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..ab268ca --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Get version from package.json. +VERSION=$(node -p -e "require('./package.json').version") + +# Set alias to latest if branch is main, dev if develop, or commit hash if others. +if [ "$(git branch --show-current)" == "main" ]; then + # Check if the working directory is clean. + if [ -n "$(git status --porcelain)" ]; then + echo "Working directory is not clean. Commit changes before releasing." + exit 1 + fi + + # Check if the local main branch is up-to-date with the remote main branch. + git fetch + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "Local main branch is not up-to-date with the remote main branch. Push changes before releasing." + exit 1 + fi +elif [ "$(git branch --show-current)" == "develop" ]; then + # Check if the working directory is clean. + if [ -n "$(git status --porcelain)" ]; then + echo "Working directory is not clean. Commit changes before releasing." + exit 1 + fi + + # Check if the local main branch is up-to-date with the remote main branch. + git fetch + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/develop)" ]; then + echo "Local main branch is not up-to-date with the remote main branch. Push changes before releasing." + exit 1 + fi +else + VERSION=$(git rev-parse --short HEAD) +fi + +# Set tags. +TAGS=( + "3t.network/butlerbot:$VERSION" + "git.3t.network/3t.network/butlerbot:$VERSION" +) + +# Append to tags if the branch is main or develop. +if [ "$ALIAS" == "latest" ]; then + TAGS+=("3t.network/butlerbot:latest") + TAGS+=("git.3t.network/3t.network/butlerbot:latest") +elif [ "$ALIAS" == "dev" ]; then + TAGS+=("3t.network/butlerbot:dev") + TAGS+=("git.3t.network/3t.network/butlerbot:dev") +fi + +# cd to the project root directory. +cd "$(dirname "$0")/.." +docker build -t butlerbot:$VERSION . + +# Tag the Docker image. +for TAG in "${TAGS[@]}"; do + docker tag butlerbot:$VERSION "$TAG" +done + +# Create docker-compose.override.yml with the version. +cat > docker-compose.override.yml < + option + .setName('prompt') + .setDescription('The prompt to generate an image from') + .setRequired(true) + ); + +// Helper function to poll the prediction status +async function pollPredictionStatus( + predictionId, + maxAttempts = 5, + interval = 2000 +) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const latestPrediction = await replicate.predictions.get(predictionId); + + if ( + latestPrediction.status !== 'starting' && + latestPrediction.status !== 'processing' + ) { + return latestPrediction; + } + + // Wait before checking again + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error('Prediction timed out.'); +} + +// Helper function to download the image from a URL +async function downloadImage(url) { + try { + const response = await axios.get(url, { responseType: 'arraybuffer' }); + return Buffer.from(response.data); + } catch (error) { + throw new Error('Failed to download the image.'); + } +} + +async function execute(interaction) { + await interaction.deferReply(); + + if (!process.env.REPLICATE_API_KEY) { + await interaction.reply('The bot is missing the Replicate API key.'); + return; + } + + const prompt = interaction.options.getString('prompt'); + + try { + // Create image generation prediction + const prediction = await replicate.predictions.create({ + model: 'black-forest-labs/flux-schnell', + input: { prompt }, + }); + + // Poll until the image generation is complete + const completedPrediction = await pollPredictionStatus(prediction.id); + + if (!completedPrediction || !completedPrediction.output) { + throw new Error('Failed to generate the image.'); + } + + const imageUrl = completedPrediction.output[0]; + + // Download the generated image + const imageBuffer = await downloadImage(imageUrl); + + // Create an attachment to send the image back to the user + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'image.png', + }); + + // Edit the deferred reply to include the generated image + await interaction.editReply({ files: [attachment] }); + } catch (error) { + // Provide a more informative error message to the user + console.error(error); // Log the error for debugging purposes + await interaction.editReply(`An error occurred: ${error.message}`); + } +} + +export default { data, execute }; diff --git a/src/commands/db/game.js b/src/commands/db/game.js new file mode 100644 index 0000000..0aa3d1f --- /dev/null +++ b/src/commands/db/game.js @@ -0,0 +1,207 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { + listGameNames, + getGame, + setGame, + deleteGame, + deleteField, +} from '../../utils/db/game.js'; + +const data = new SlashCommandBuilder() + .setName('game') + .setDescription('Perform ButlerBot game database operations.') + .addSubcommand((subcommand) => + subcommand.setName('list').setDescription('List all games in the database.') + ) + .addSubcommand((subcommand) => + subcommand + .setName('get') + .setDescription('Get a game from the database.') + .addStringOption((option) => + option + .setName('game') + .setDescription('The name of the game to get.') + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set a key value pair on an game in the database.') + .addStringOption((option) => + option + .setName('game') + .setDescription('The name of the game to set the field in.') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('key') + .setDescription('The key or label to set.') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('value') + .setDescription('The value of the field to set.') + .setRequired(true) + .setMinLength(1) + .setMaxLength(1024) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete a game, or a field from a game.') + .addStringOption((option) => + option + .setName('game') + .setDescription( + 'The name of the game to remove, or remove a field from.' + ) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('key') + .setDescription('The key or label of the field to remove.') + .setRequired(false) + ) + ); + +function createEmbedFromGame(game) { + const embed = new EmbedBuilder().setTitle(game.game).setColor(0xff0000); + + // Add fields for each key value pair - skip name, _id and guild. + embed.addFields( + Object.entries(game) + .filter(([key]) => !['_id', 'game', 'guild'].includes(key)) + .map(([key, value]) => ({ name: key, value: String(value) })) + ); + + if (embed.length > 6000) { + throw new Error('Embed size exceeds maximum.'); + } + + return embed; +} + +async function execute(interaction) { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'list': { + const gameNames = await listGameNames(); + + if (gameNames.length === 0) { + await interaction.editReply('No games found in the database.'); + return; + } + + const embed = new EmbedBuilder() + .setTitle('Game Database') + .setColor(0xff0000) + .setDescription('List of games in the database.'); + + embed.addFields({ + name: 'Games', + value: gameNames.map((game) => game.game).join('\n'), + }); + + await interaction.editReply({ embeds: [embed] }); + break; + } + + case 'get': { + const gameName = interaction.options.getString('game'); + + const game = await getGame(gameName); + + if (!game) { + await interaction.editReply( + `Game ${gameName} not found in the database.` + ); + return; + } + + try { + const embed = createEmbedFromGame(game); + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply( + 'Embed too large to send. Game has not been retrieved.' + ); + } + break; + } + + case 'set': { + const gameName = interaction.options.getString('game'); + const key = interaction.options.getString('key'); + const value = interaction.options.getString('value'); + + const oldGame = await getGame(gameName); + await setGame(gameName, key, value); + const updatedGame = await getGame(gameName); + + try { + const embed = createEmbedFromGame(updatedGame); + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + // Revert game back to original state if the embed is too large. + if (oldGame) { + // If key already existed, revert to old value, else delete the key. + if (oldGame[key]) { + await setGame(gameName, key, oldGame[key]); + } else { + await deleteField(gameName, key); + } + } + await interaction.editReply( + 'Embed too large to send. Game has not been updated.' + ); + } + break; + } + + case 'delete': { + const gameName = interaction.options.getString('game'); + const key = interaction.options.getString('key'); + + if (key) { + await deleteField(gameName, key); + const updatedGame = await getGame(gameName); + + if (!updatedGame) { + await interaction.editReply( + `Game ${gameName} deleted from the database.` + ); + return; + } + + try { + const embed = createEmbedFromGame(updatedGame); + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply( + 'Embed too large to send. Field deleted from game but game has not been retrieved.' + ); + } + } else { + await deleteGame(gameName); + await interaction.editReply( + `Game ${gameName} deleted from the database.` + ); + } + break; + } + + default: + await interaction.editReply('Unknown subcommand.'); + break; + } +} + +export default { data, execute }; diff --git a/src/commands/lookup/imdb.js b/src/commands/lookup/imdb.js index 84bebdd..e76b477 100644 --- a/src/commands/lookup/imdb.js +++ b/src/commands/lookup/imdb.js @@ -1,6 +1,10 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import axios from 'axios'; +if (!process.env.OMDB_API_KEY) { + throw new Error('OMDB_API_KEY is not set in the environment variables.'); +} + const OMDB_API_URL = 'http://www.omdbapi.com/?apikey={apiKey}'; const FIELDS = [ 'Title', @@ -32,40 +36,65 @@ const data = new SlashCommandBuilder() .setMaxValue(2100) ); -async function execute(interaction) { - const filmName = interaction.options.getString('film_name'); - const filmYear = interaction.options.getInteger('film_year'); - let embed = new EmbedBuilder().setTitle('IMDB').setColor(0xf5de50); - - let omdbQueryUrl = OMDB_API_URL.replace('{apiKey}', process.env.OMDB_API_KEY); - omdbQueryUrl += `&t=${encodeURIComponent(filmName.toLowerCase())}`; - +// Helper function to construct OMDB query URL +function buildOmdbUrl(filmName, filmYear) { + let url = OMDB_API_URL.replace('{apiKey}', process.env.OMDB_API_KEY); + url += `&t=${encodeURIComponent(filmName.toLowerCase())}`; if (filmYear) { - omdbQueryUrl += `&y=${filmYear}`; + url += `&y=${filmYear}`; } + return url; +} - try { - const response = await axios.get(omdbQueryUrl, { timeout: 5000 }); - const result = response.data; +async function execute(interaction) { + await interaction.deferReply(); - FIELDS.forEach((field) => { - const fieldValue = result[field]; - if (fieldValue) { - embed.addFields({ name: field, value: fieldValue }); - } - }); - - const poster = result.Poster; - if (poster) { - embed.setImage(poster); - } - } catch (error) { - console.error(`Error looking up film: ${filmName}.`, error); - await interaction.reply('An error occurred when querying the OMDB API.'); + if (!process.env.OMDB_API_KEY) { + await interaction.editReply('The bot is missing the OMDB API key.'); return; } - await interaction.reply({ embeds: [embed] }); + const filmName = interaction.options.getString('film_name'); + const filmYear = interaction.options.getInteger('film_year'); + + try { + const omdbQueryUrl = buildOmdbUrl(filmName, filmYear); + const response = await axios.get(omdbQueryUrl, { timeout: 5000 }); + const result = response.data; + + // Check if the movie was found + if (result.Response === 'False') { + await interaction.editReply( + `No results found for the film: ${filmName}.` + ); + return; + } + + // Build the embed with film details + const embed = new EmbedBuilder() + .setTitle(result.Title || 'IMDB Film Info') + .setColor(0xf5de50); + + // Add the fields to the embed + FIELDS.forEach((field) => { + if (result[field]) { + embed.addFields({ name: field, value: result[field], inline: true }); + } + }); + + // Add poster image if available + if (result.Poster && result.Poster !== 'N/A') { + embed.setImage(result.Poster); + } + + // Send the result to the user + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error(`Error looking up film: ${filmName}`, error); + await interaction.editReply( + 'An error occurred when querying the OMDB API.' + ); + } } export default { data, execute }; diff --git a/src/commands/lookup/plant.js b/src/commands/lookup/plant.js index 1b9375f..6d21690 100644 --- a/src/commands/lookup/plant.js +++ b/src/commands/lookup/plant.js @@ -5,6 +5,10 @@ const API_URL = 'https://my-api.plantnet.org/v2/identify/all?api-key={apiKey}'; const LEAF_THUMBNAIL = 'https://cdn.discordapp.com/attachments/870024275556446328/1006249009201033287/monstera.png'; +if (!process.env.PLANTNET_API_KEY) { + throw new Error('PLANTNET_API_KEY is not set in the environment variables.'); +} + const data = new SlashCommandBuilder() .setName('plant') .setDescription('Identify a plant by uploading an image.') @@ -15,14 +19,48 @@ const data = new SlashCommandBuilder() .setRequired(true) ); +// Helper function to generate plant details +function generatePlantDetail(commonName, scientificName, confidence) { + let detail = ''; + + if (0.0 <= confidence && confidence < 0.25) { + detail = `I'm really not sure, but I think that looks like a **${commonName}**?`; + } else if (0.25 <= confidence && confidence < 0.5) { + detail = `I think that looks like a **${commonName}**!`; + } else if (0.5 <= confidence && confidence < 0.75) { + detail = `That looks like a **${commonName}**!`; + } else if (confidence >= 0.75) { + detail = `That is a really cool **${commonName}**!`; + } + + const imageUrl = `https://www.google.com/search?q=${encodeURIComponent( + commonName || scientificName + )}&tbm=isch`; + + detail += `\n\n**Scientific Name**: ${scientificName}`; + detail += `\n\n**Images**: [More images](${imageUrl})`; + detail += `\n\n**Confidence**: ${(confidence * 100).toFixed(3)}%`; + + return detail; +} + async function execute(interaction) { + await interaction.deferReply(); + + if (!process.env.PLANTNET_API_KEY) { + await interaction.editReply('The bot is missing the PlantNet API key.'); + return; + } + const image = interaction.options.getAttachment('image'); - let embed = new EmbedBuilder() - .setTitle('Plant Detector™️') - .setThumbnail(LEAF_THUMBNAIL) - .setColor(0xff0000); try { + const embed = new EmbedBuilder() + .setTitle('Plant Detector™️') + .setThumbnail(LEAF_THUMBNAIL) + .setColor(0xff0000); + + // API call to identify the plant const response = await axios.get( API_URL.replace('{apiKey}', process.env.PLANTNET_API_KEY), { @@ -35,41 +73,38 @@ async function execute(interaction) { ); const bestMatch = response.data.results[0]; + + // Check if there is a valid result + if (!bestMatch) { + await interaction.editReply( + 'No plant could be identified from the image.' + ); + return; + } + const commonName = bestMatch.species.commonNames[0] || 'No common name found'; const scientificName = bestMatch.species.scientificNameWithoutAuthor; const confidence = parseFloat(bestMatch.score); - let detail = ''; - - if (0.0 <= confidence && confidence < 0.25) { - detail = `I'm really not sure, but I think that looks like a **${commonName}**?`; - } else if (0.25 <= confidence && confidence < 0.5) { - detail = `I think that looks like a **${commonName}**!`; - } else if (0.5 <= confidence && confidence < 0.75) { - detail = `That looks like a **${commonName}**!`; - } else if (confidence >= 0.75) { - detail = `That is a really cool **${commonName}**!`; - } - - const imageUrl = `https://www.google.com/search?q=${encodeURIComponent(commonName || scientificName)}&tbm=isch`; - - detail += `\n\n**Scientific Name**: ${scientificName}`; - detail += `\n\n**Images**: [More images](${imageUrl})`; - detail += `\n\n**Confidence**: ${(confidence * 100).toFixed(3)}%`; + const plantDetails = generatePlantDetail( + commonName, + scientificName, + confidence + ); embed .setColor(0x00ff00) - .addFields({ name: 'Plant Details', value: detail }); + .addFields({ name: 'Plant Details', value: plantDetails }); + + // Send the result to the user + await interaction.editReply({ embeds: [embed] }); } catch (error) { - console.error(`Error looking up plant: ${image.url}`, error); - await interaction.reply( + console.error(error); // Log the error for debugging + await interaction.editReply( 'An error occurred when querying the PlantNet API.' ); - return; } - - await interaction.reply({ embeds: [embed] }); } export default { data, execute }; diff --git a/src/commands/memes/eyecandy.js b/src/commands/memes/eyecandy.js index ab9a94f..c2af22b 100644 --- a/src/commands/memes/eyecandy.js +++ b/src/commands/memes/eyecandy.js @@ -7,33 +7,42 @@ const data = new SlashCommandBuilder() .setName('eyecandy') .setDescription('Returns a random gif of Gerard Butler.'); -async function execute(interaction) { - const giphyApiKey = process.env.GIPHY_API_KEY; - - if (!giphyApiKey) { - await interaction.reply( - 'The bot has not been configured with a Giphy API key.' - ); - return; - } - +async function fetchGif() { const randomOffset = Math.floor(Math.random() * 100); - const giphyQueryUrl = `${GIPHY_API_URL}?api_key=${giphyApiKey}&q=gerard+butler&limit=1&offset=${randomOffset}`; + const giphyQueryUrl = `${GIPHY_API_URL}?api_key=${process.env.GIPHY_API_KEY}&q=gerard+butler&limit=1&offset=${randomOffset}`; try { const response = await axios.get(giphyQueryUrl, { timeout: 5000 }); const result = response.data; if (result.data.length === 0) { - await interaction.reply('No gifs found.'); - return; + throw new Error('No gifs found for the given query.'); } - const imageUrl = result.data[0].images.original.url; - await interaction.reply(imageUrl); + return result.data[0].images.original.url; } catch (error) { - console.error('Error querying the Giphy API:', error); - await interaction.reply('An error occurred when querying the Giphy API.'); + console.error('Error fetching GIF:', error.message); + throw new Error('Failed to retrieve a GIF from Giphy.'); + } +} + +async function execute(interaction) { + await interaction.deferReply(); + + if (!process.env.GIPHY_API_KEY) { + await interaction.reply('The bot is missing the GIPHY API key.'); + return; + } + + try { + // Fetch a random gif of Gerard Butler. + const gifUrl = await fetchGif(); + + // Reply with the gif. + await interaction.editReply(gifUrl); + } catch (error) { + console.error('Error executing the command:', error.message); + await interaction.editReply(`An error occurred: ${error.message}`); } } diff --git a/src/commands/utilities/countdown.js b/src/commands/utilities/countdown.js index 9205706..cdb5c14 100644 --- a/src/commands/utilities/countdown.js +++ b/src/commands/utilities/countdown.js @@ -5,19 +5,15 @@ const data = new SlashCommandBuilder() .setDescription('Start a five second countdown.'); async function execute(interaction) { - // Initial response to acknowledge the command await interaction.reply({ content: 'Starting countdown...' }); - // Initial delay before starting the countdown await new Promise((resolve) => setTimeout(resolve, 2000)); // 2-second delay - // Edit the response with the countdown for (let i = 5; i > 0; i--) { await interaction.editReply({ content: String(i) }); await new Promise((resolve) => setTimeout(resolve, 1000)); // 1-second delay between numbers } - // Final message await interaction.editReply({ content: '🎉 GO! 🎉' }); } diff --git a/src/index.js b/src/index.js index 50192a8..8a33bc7 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { Client, Collection, Events, GatewayIntentBits } from 'discord.js'; import { loadCommandModules } from './utilities/commandModules.js'; @@ -42,4 +43,4 @@ client.once(Events.ClientReady, (readyClient) => { console.log(`Ready! Logged in as ${readyClient.user.tag}`); }); -client.login(process.env.DISCORD_TOKEN); +client.login(process.env.DISCORD_API_KEY); diff --git a/src/music/music.js b/src/music/music.js new file mode 100644 index 0000000..e7599f6 --- /dev/null +++ b/src/music/music.js @@ -0,0 +1,132 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { Interaction } from 'discord.js'; + +// Maps to track queues and voice connections per guild +const queueMap = new Map(); // { guildId: [track1, track2, ...] } +const connectionMap = new Map(); // { guildId: VoiceConnection } + +const data = new SlashCommandBuilder() + .setName('music') + .setDescription('Perform ButlerBot music operations.') + .addSubcommand((subcommand) => + subcommand + .setName('play') + .setDescription('Play a track.') + .addStringOption((option) => + option + .setName('track') + .setDescription('The track to play.') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('provider') + .setDescription('The provider of the track.') + .setRequired(false) + .addChoices( + { name: 'YouTube', value: 'youtube' }, + { name: 'Spotify', value: 'spotify' }, + { name: 'Local', value: 'local' } + ) + ) + ) + .addSubcommand((subcommand) => + subcommand.setName('pause').setDescription('Pause the current track.') + ) + .addSubcommand((subcommand) => + subcommand.setName('resume').setDescription('Resume the current track.') + ) + .addSubcommand((subcommand) => + subcommand.setName('skip').setDescription('Skip the current track.') + ) + .addSubcommand((subcommand) => + subcommand.setName('stop').setDescription('Stop the current track.') + ) + .addSubcommand((subcommand) => + subcommand.setName('queue').setDescription('Show the current track queue.') + ) + .addSubcommand((subcommand) => + subcommand.setName('clear').setDescription('Clear the current track queue.') + ) + .addSubcommand((subcommand) => + subcommand.setName('leave').setDescription('Leave the voice channel.') + ) + .addSubcommand((subcommand) => + subcommand.setName('join').setDescription('Join the voice channel.') + ); + +/** + * Handles the music command. + * @param {Interaction} interaction The interaction object. + */ +async function execute(interaction) { + await interaction.deferReply(); + const subcommand = interaction.options.getSubcommand(); + const guildId = interaction.guild.id; + + switch (subcommand) { + case 'play': + await handlePlay(interaction, guildId); + break; + case 'pause': + await handlePause(interaction, guildId); + break; + case 'resume': + await handleResume(interaction, guildId); + break; + case 'skip': + await handleSkip(interaction, guildId); + break; + case 'stop': + await handleStop(interaction, guildId); + break; + case 'queue': + await handleQueue(interaction, guildId); + break; + case 'clear': + await handleClear(interaction, guildId); + break; + case 'leave': + await handleLeave(interaction, guildId); + break; + case 'join': + await handleJoin(interaction, guildId); + break; + default: + await interaction.editReply('Invalid music subcommand.'); + } +} + +/** + * Handles the play subcommand. + * @param {Interaction} interaction The interaction object. + * @param {string} guildId The guild ID. + * @returns {Promise} + */ + +async function handlePlay(interaction, guildId) { + const track = interaction.options.getString('track'); + const provider = interaction.options.getString('provider') || 'youtube'; + + // Check if the user is in a voice channel + const voiceChannel = interaction.member.voice.channel; + if (!voiceChannel) { + await interaction.editReply( + 'You need to be in a voice channel to play music.' + ); + return; + } + + // Get the queue for the guild + const queue = queueMap.get(guildId) || []; + queue.push({ track, provider }); + queueMap.set(guildId, queue); + + // Join the voice channel + await handleJoin(interaction, guildId); + + // Play the track + await interaction.editReply(`Playing track: ${track}`); +} + +export default { data, execute }; diff --git a/src/register-slash-commands.js b/src/register-slash-commands.js index d9071c8..9ef0c89 100644 --- a/src/register-slash-commands.js +++ b/src/register-slash-commands.js @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { REST, Routes } from 'discord.js'; import { loadCommandModules } from './utilities/commandModules.js'; @@ -7,7 +8,7 @@ const commands = commandModules.map((commandModule) => commandModule.data.toJSON() ); -const rest = new REST().setToken(process.env.DISCORD_TOKEN); +const rest = new REST().setToken(process.env.DISCORD_API_KEY); try { console.log('Started refreshing application (/) commands.'); @@ -19,6 +20,8 @@ try { ); console.log(`Successfully reloaded ${data.length} application (/) commands.`); + // Exit the process. + process.exit(); } catch (error) { // And of course, make sure you catch and log any errors! console.error(error); diff --git a/src/utils/db/game.js b/src/utils/db/game.js new file mode 100644 index 0000000..8e3ba37 --- /dev/null +++ b/src/utils/db/game.js @@ -0,0 +1,37 @@ +import { MongoClient } from 'mongodb'; + +if (!process.env.MONGODB_URI) { + throw new Error('MONGODB_URI is not set in the environment variables.'); +} + +const client = new MongoClient(process.env.MONGODB_URI); +await client.connect(); + +const database = client.db('butler_db'); +const collection = database.collection('GameFeatures'); + +export async function listGameNames() { + return collection.find({}, { projection: { game: 1 } }).toArray(); +} + +export async function getGame(game) { + return collection.findOne({ game }); +} + +export async function setGame(game, key, value) { + // If game is not found, create a new document with game field set to game, and key field set to value. + // Overwrite the value of the key field if it already exists. + return collection.updateOne( + { game }, + { $set: { game, [key]: value } }, + { upsert: true } + ); +} + +export async function deleteGame(game) { + return collection.deleteOne({ game }); +} + +export async function deleteField(game, key) { + return collection.updateOne({ game }, { $unset: { [key]: '' } }); +}