Add features

This commit is contained in:
2024-09-12 14:37:08 +00:00
parent f5948621f0
commit a89150fc2f
22 changed files with 1145 additions and 99 deletions

11
.dockerignore Normal file
View File

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

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
DISCORD_APPLICATION_ID=
DISCORD_API_KEY=
GIPHY_API_KEY=
PLANTNET_API_KEY=
OMDB_API_KEY=
REPLICATE_API_KEY=

4
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# ---> Build specific exclusions
docker-compose.override.yml
# ---> Windows
# Windows thumbnail cache files
Thumbs.db
@@ -48,6 +51,7 @@ $RECYCLE.BIN/
# Icon must end with two \r
Icon
# Thumbnails
._*

19
Dockerfile Normal file
View File

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

View File

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

5
docker-compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
butlerbot:
image: git.3t.network/3t.network/butlerbot:latest
env_file:
- .env

View File

@@ -1,5 +0,0 @@
DISCORD_TOKEN=MTI4MDQ0NDY1MjUyODQ2Nz...
DISCORD_APPLICATION_ID=12804446...
GIPHY_API_KEY=mg7MHuxn42R4TE33...
PLANTNET_API_KEY=2b10p4Zb7K...
OMDB_API_KEY=96e...

329
package-lock.json generated
View File

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

View File

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

76
scripts/build.sh Executable file
View File

@@ -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 <<EOF
services:
butlerbot:
image: git.3t.network/3t.network/butlerbot:$VERSION
EOF
echo "docker-compose.override.yml created."
# Print build information.
echo "butlerbot:$VERSION built successfully."
echo "Tags:"
for TAG in "${TAGS[@]}"; do
echo " $TAG"
done
echo "Run 'docker compose up' to start the bot."

View File

@@ -0,0 +1,3 @@
#!/bin/bash
docker compose run --rm butlerbot register-slash-commands

3
scripts/start.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
docker compose up

99
src/commands/ai/image.js Normal file
View File

@@ -0,0 +1,99 @@
import { SlashCommandBuilder, AttachmentBuilder } from 'discord.js';
import axios from 'axios';
import Replicate from 'replicate';
if (!process.env.REPLICATE_API_KEY) {
throw new Error('REPLICATE_API_KEY is not set in the environment variables.');
}
const replicate = new Replicate({
auth: process.env.REPLICATE_API_KEY,
});
const data = new SlashCommandBuilder()
.setName('image')
.setDescription('Generate an image based on a prompt.')
.addStringOption((option) =>
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 };

207
src/commands/db/game.js Normal file
View File

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

View File

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

View File

@@ -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,31 +19,8 @@ const data = new SlashCommandBuilder()
.setRequired(true)
);
async function execute(interaction) {
const image = interaction.options.getAttachment('image');
let embed = new EmbedBuilder()
.setTitle('Plant Detector™')
.setThumbnail(LEAF_THUMBNAIL)
.setColor(0xff0000);
try {
const response = await axios.get(
API_URL.replace('{apiKey}', process.env.PLANTNET_API_KEY),
{
params: {
images: image.url,
organs: 'leaf',
},
timeout: 5000,
}
);
const bestMatch = response.data.results[0];
const commonName =
bestMatch.species.commonNames[0] || 'No common name found';
const scientificName = bestMatch.species.scientificNameWithoutAuthor;
const confidence = parseFloat(bestMatch.score);
// Helper function to generate plant details
function generatePlantDetail(commonName, scientificName, confidence) {
let detail = '';
if (0.0 <= confidence && confidence < 0.25) {
@@ -52,24 +33,78 @@ async function execute(interaction) {
detail = `That is a really cool **${commonName}**!`;
}
const imageUrl = `https://www.google.com/search?q=${encodeURIComponent(commonName || scientificName)}&tbm=isch`;
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)}%`;
embed
.setColor(0x00ff00)
.addFields({ name: 'Plant Details', value: detail });
} catch (error) {
console.error(`Error looking up plant: ${image.url}`, error);
await interaction.reply(
'An error occurred when querying the PlantNet API.'
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');
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),
{
params: {
images: image.url,
organs: 'leaf',
},
timeout: 5000,
}
);
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;
}
await interaction.reply({ embeds: [embed] });
const commonName =
bestMatch.species.commonNames[0] || 'No common name found';
const scientificName = bestMatch.species.scientificNameWithoutAuthor;
const confidence = parseFloat(bestMatch.score);
const plantDetails = generatePlantDetail(
commonName,
scientificName,
confidence
);
embed
.setColor(0x00ff00)
.addFields({ name: 'Plant Details', value: plantDetails });
// Send the result to the user
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error); // Log the error for debugging
await interaction.editReply(
'An error occurred when querying the PlantNet API.'
);
}
}
export default { data, execute };

View File

@@ -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.');
throw new Error('No gifs found for the given query.');
}
return result.data[0].images.original.url;
} catch (error) {
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;
}
const imageUrl = result.data[0].images.original.url;
await interaction.reply(imageUrl);
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 querying the Giphy API:', error);
await interaction.reply('An error occurred when querying the Giphy API.');
console.error('Error executing the command:', error.message);
await interaction.editReply(`An error occurred: ${error.message}`);
}
}

View File

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

View File

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

132
src/music/music.js Normal file
View File

@@ -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<void>}
*/
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 };

View File

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

37
src/utils/db/game.js Normal file
View File

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