diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2a9062d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.vscode + +node_modules +tmp +logs + +.env +.env.keys + +command-leaderboard.json +command-last-run.json +blacklist.json +jeopardy.json + +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/.env.example b/.env.example index 61ad2138..b90e6676 100644 --- a/.env.example +++ b/.env.example @@ -1,45 +1,51 @@ # Discord-related info -XIAO_TOKEN= +XIAO_TOKEN=your_discord_bot_token # Separate OWNERS with a , -OWNERS= +OWNERS=123456789012345678 LOVER_USER_ID= -XIAO_PREFIX= -INVITE= +XIAO_PREFIX=! +INVITE=https://discord.gg/your-server REPORT_CHANNEL_ID= JOIN_LEAVE_CHANNEL_ID= # Redis info -REDIS_HOST= -REDIS_PASS= +# For docker-compose, keep REDIS_HOST=redis +REDIS_HOST=redis +REDIS_PASS=change_me_redis_password + +# Runtime +TZ=UTC +# Folder used to persist bot state files in Docker +XIAO_STATE_DIR=/data # Emoji IDs SUCCESS_EMOJI_ID= FAILURE_EMOJI_ID= GOLD_FISH_EMOJI_ID= -GOLD_FISH_EMOJI_NAME= +GOLD_FISH_EMOJI_NAME=Gold Fish MOCKING_EMOJI_ID= MOCKING_EMOJI_NAME= SILVER_FISH_EMOJI_ID= -SILVER_FISH_EMOJI_NAME= +SILVER_FISH_EMOJI_NAME=Silver Fish PORTAL_EMOJI_ID= -PORTAL_EMOJI_NAME= +PORTAL_EMOJI_NAME=PORTAL LOADING_EMOJI_ID= MEGA_EVOLVE_EMOJI_ID= -MEGA_EVOLVE_EMOJI_NAME= +MEGA_EVOLVE_EMOJI_NAME=MEGA NAME_RATER_EMOJI_ID= NAME_RATER_EMOJI_NAME= # API Keys, IDs, and Secrets -ANILIST_USERNAME= -BITLY_KEY= -CLEVERBOT_KEY= -GITHUB_ACCESS_TOKEN= -GOV_KEY= -IDIOT_URL= -REMOVEBG_KEY= -SAUCENAO_KEY= -THECATAPI_KEY= -THEDOGAPI_KEY= -WEBSTER_KEY= -XIAO_GITHUB_REPO_NAME= -XIAO_GITHUB_REPO_USERNAME= +ANILIST_USERNAME=AniList +BITLY_KEY=your_bitly_key +CLEVERBOT_KEY=your_cleverbot_key +GITHUB_ACCESS_TOKEN=your_github_token +GOV_KEY=your_nasa_api_key +IDIOT_URL=https://en.wikipedia.org/wiki/Idiot +REMOVEBG_KEY=your_removebg_key +SAUCENAO_KEY=your_saucenao_key +THECATAPI_KEY=your_thecatapi_key +THEDOGAPI_KEY=your_thedogapi_key +WEBSTER_KEY=your_webster_key +XIAO_GITHUB_REPO_NAME=xiao +XIAO_GITHUB_REPO_USERNAME=xiaobotdev diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..98f292af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24-bookworm + +WORKDIR /app + +# System dependencies used by native modules and image/audio commands. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + ffmpeg \ + git \ + graphicsmagick \ + imagemagick \ + libgif-dev \ + libjpeg62-turbo-dev \ + liblqr-1-0 \ + libpango1.0-dev \ + librsvg2-dev \ + make \ + g++ \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ +RUN npm install --omit=dev && npm cache clean --force + +COPY . . + +# Refresh parse-domain data when available. +RUN npx --yes parse-domain-update || true + +ENV NODE_ENV=production + +CMD ["node", "Xiao.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..19f2da19 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + redis: + image: redis:7-alpine + container_name: xiao-redis + restart: unless-stopped + command: + - redis-server + - --appendonly + - "yes" + - --requirepass + - ${REDIS_PASS:-change_me_redis_password} + volumes: + - redis-data:/data + + xiao: + build: + context: . + dockerfile: Dockerfile + container_name: xiao-bot + restart: unless-stopped + depends_on: + - redis + env_file: + - .env + environment: + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PASS: ${REDIS_PASS:-change_me_redis_password} + TZ: ${TZ:-UTC} + XIAO_STATE_DIR: ${XIAO_STATE_DIR:-/data} + volumes: + - xiao-tmp:/app/tmp + - xiao-state:/data + - ./.env:/app/.env:ro + +volumes: + redis-data: + xiao-tmp: + xiao-state: \ No newline at end of file diff --git a/framework/Client.js b/framework/Client.js index cc48fd88..41859b89 100644 --- a/framework/Client.js +++ b/framework/Client.js @@ -7,6 +7,21 @@ const Registry = require('./Registry'); const Dispatcher = require('./Dispatcher'); require('./Extensions'); +const STATE_DIR = process.env.XIAO_STATE_DIR + ? path.resolve(process.env.XIAO_STATE_DIR) + : path.join(__dirname, '..'); + +function resolveStatePath(fileName) { + return path.join(STATE_DIR, fileName); +} + +function writeJsonFile(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const buf = Buffer.from(`${JSON.stringify(data, null, '\t')}\n`); + fs.writeFileSync(filePath, buf, { encoding: 'utf8' }); + return data; +} + module.exports = class CommandClient extends Client { constructor(options) { super(options); @@ -157,7 +172,11 @@ module.exports = class CommandClient extends Client { } importBlacklist() { - const read = fs.readFileSync(path.join(__dirname, '..', 'blacklist.json'), { encoding: 'utf8' }); + const filePath = resolveStatePath('blacklist.json'); + if (!fs.existsSync(filePath)) { + return writeJsonFile(filePath, { guild: [], user: [] }); + } + const read = fs.readFileSync(filePath, { encoding: 'utf8' }); const file = JSON.parse(read); if (typeof file !== 'object' || Array.isArray(file)) return null; if (!file.guild || !file.user) return null; @@ -191,12 +210,17 @@ module.exports = class CommandClient extends Client { } text += '\n ]\n}\n'; const buf = Buffer.from(text); - fs.writeFileSync(path.join(__dirname, '..', 'blacklist.json'), buf, { encoding: 'utf8' }); + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(resolveStatePath('blacklist.json'), buf, { encoding: 'utf8' }); return buf; } importCommandLeaderboard(add = false) { - const read = fs.readFileSync(path.join(__dirname, '..', 'command-leaderboard.json'), { + const filePath = resolveStatePath('command-leaderboard.json'); + if (!fs.existsSync(filePath)) { + return writeJsonFile(filePath, {}); + } + const read = fs.readFileSync(filePath, { encoding: 'utf8' }); const file = JSON.parse(read); @@ -221,14 +245,19 @@ module.exports = class CommandClient extends Client { text = text.slice(0, -1); text += '\n}\n'; const buf = Buffer.from(text); - fs.writeFileSync(path.join(__dirname, '..', 'command-leaderboard.json'), buf, { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(resolveStatePath('command-leaderboard.json'), buf, { encoding: 'utf8' }); return buf; } importLastRun() { - const read = fs.readFileSync(path.join(__dirname, '..', 'command-last-run.json'), { + const filePath = resolveStatePath('command-last-run.json'); + if (!fs.existsSync(filePath)) { + return writeJsonFile(filePath, {}); + } + const read = fs.readFileSync(filePath, { encoding: 'utf8' }); const file = JSON.parse(read); @@ -254,7 +283,8 @@ module.exports = class CommandClient extends Client { text = text.slice(0, -1); text += '\n}\n'; const buf = Buffer.from(text); - fs.writeFileSync(path.join(__dirname, '..', 'command-last-run.json'), buf, { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(resolveStatePath('command-last-run.json'), buf, { encoding: 'utf8' }); return buf; diff --git a/structures/JeopardyScrape.js b/structures/JeopardyScrape.js index 6b85d801..5ff331eb 100644 --- a/structures/JeopardyScrape.js +++ b/structures/JeopardyScrape.js @@ -8,6 +8,14 @@ const path = require('path'); const { checkFileExists } = require('../util/Util'); const rounds = ['jeopardy_round', 'double_jeopardy_round', 'final_jeopardy_round']; +const STATE_DIR = process.env.XIAO_STATE_DIR + ? path.resolve(process.env.XIAO_STATE_DIR) + : path.join(__dirname, '..'); + +function resolveStatePath(fileName) { + return path.join(STATE_DIR, fileName); +} + module.exports = class JeopardyScrape { constructor(client) { Object.defineProperty(this, 'client', { value: client }); @@ -70,17 +78,17 @@ module.exports = class JeopardyScrape { } async importData() { - const read = await fs.promises.readFile(path.join(__dirname, '..', 'jeopardy.json'), { encoding: 'utf8' }); + const read = await fs.promises.readFile(resolveStatePath('jeopardy.json'), { encoding: 'utf8' }); const { seasons, gameIDs } = JSON.parse(read); - this.gameIDs = gameIDs; - this.seasons = seasons; + this.gameIDs = Array.isArray(gameIDs) ? gameIDs : []; + this.seasons = Array.isArray(seasons) ? seasons : []; this.clues = await this.importClues(); this.imported = true; return this; } importClues() { - const pipeline = fs.createReadStream(path.join(__dirname, '..', 'jeopardy.json'), { encoding: 'utf8' }) + const pipeline = fs.createReadStream(resolveStatePath('jeopardy.json'), { encoding: 'utf8' }) .pipe(parser()) .pipe(pick({ filter: 'clues' })) .pipe(streamArray()); @@ -96,19 +104,35 @@ module.exports = class JeopardyScrape { gameIDs: this.gameIDs, seasons: this.seasons })); - fs.writeFileSync(path.join(__dirname, '..', 'jeopardy.json'), buf, { encoding: 'utf8' }); + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(resolveStatePath('jeopardy.json'), buf, { encoding: 'utf8' }); return buf; } async checkForUpdates() { if (!this.imported) { - const fileExists = await checkFileExists(path.join(__dirname, '..', 'jeopardy.json')); + const fileExists = await checkFileExists(resolveStatePath('jeopardy.json')); if (fileExists) { this.client.logger.info('[JEOPARDY] Importing from file...'); await this.importData(); this.client.logger.info('[JEOPARDY] Import complete!'); + } else { + this.client.logger.warn('[JEOPARDY] No jeopardy.json found. Skipping update for now.'); + this.clues = []; + this.gameIDs = []; + this.seasons = []; + this.imported = true; + this.exportData(); + return 0; } } + if (!Array.isArray(this.clues)) this.clues = []; + if (!Array.isArray(this.gameIDs)) this.gameIDs = []; + if (!Array.isArray(this.seasons)) this.seasons = []; + if (!this.seasons.length) { + this.client.logger.warn('[JEOPARDY] No seasons loaded. Skipping update.'); + return 0; + } const cluesBefore = this.clues.length; const latestSeason = this.seasons[this.seasons.length - 1]; const seasons = await this.fetchSeasons();