Add Docker support to simplify deployment

This commit is contained in:
Puechberty Arthur
2026-04-11 00:10:18 +02:00
parent 5747a60745
commit aa8f8048fe
6 changed files with 184 additions and 35 deletions
+19
View File
@@ -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*
+29 -23
View File
@@ -1,45 +1,51 @@
# Discord-related info # Discord-related info
XIAO_TOKEN= XIAO_TOKEN=your_discord_bot_token
# Separate OWNERS with a , # Separate OWNERS with a ,
OWNERS= OWNERS=123456789012345678
LOVER_USER_ID= LOVER_USER_ID=
XIAO_PREFIX= XIAO_PREFIX=!
INVITE= INVITE=https://discord.gg/your-server
REPORT_CHANNEL_ID= REPORT_CHANNEL_ID=
JOIN_LEAVE_CHANNEL_ID= JOIN_LEAVE_CHANNEL_ID=
# Redis info # Redis info
REDIS_HOST= # For docker-compose, keep REDIS_HOST=redis
REDIS_PASS= 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 # Emoji IDs
SUCCESS_EMOJI_ID= SUCCESS_EMOJI_ID=
FAILURE_EMOJI_ID= FAILURE_EMOJI_ID=
GOLD_FISH_EMOJI_ID= GOLD_FISH_EMOJI_ID=
GOLD_FISH_EMOJI_NAME= GOLD_FISH_EMOJI_NAME=Gold Fish
MOCKING_EMOJI_ID= MOCKING_EMOJI_ID=
MOCKING_EMOJI_NAME= MOCKING_EMOJI_NAME=
SILVER_FISH_EMOJI_ID= SILVER_FISH_EMOJI_ID=
SILVER_FISH_EMOJI_NAME= SILVER_FISH_EMOJI_NAME=Silver Fish
PORTAL_EMOJI_ID= PORTAL_EMOJI_ID=
PORTAL_EMOJI_NAME= PORTAL_EMOJI_NAME=PORTAL
LOADING_EMOJI_ID= LOADING_EMOJI_ID=
MEGA_EVOLVE_EMOJI_ID= MEGA_EVOLVE_EMOJI_ID=
MEGA_EVOLVE_EMOJI_NAME= MEGA_EVOLVE_EMOJI_NAME=MEGA
NAME_RATER_EMOJI_ID= NAME_RATER_EMOJI_ID=
NAME_RATER_EMOJI_NAME= NAME_RATER_EMOJI_NAME=
# API Keys, IDs, and Secrets # API Keys, IDs, and Secrets
ANILIST_USERNAME= ANILIST_USERNAME=AniList
BITLY_KEY= BITLY_KEY=your_bitly_key
CLEVERBOT_KEY= CLEVERBOT_KEY=your_cleverbot_key
GITHUB_ACCESS_TOKEN= GITHUB_ACCESS_TOKEN=your_github_token
GOV_KEY= GOV_KEY=your_nasa_api_key
IDIOT_URL= IDIOT_URL=https://en.wikipedia.org/wiki/Idiot
REMOVEBG_KEY= REMOVEBG_KEY=your_removebg_key
SAUCENAO_KEY= SAUCENAO_KEY=your_saucenao_key
THECATAPI_KEY= THECATAPI_KEY=your_thecatapi_key
THEDOGAPI_KEY= THEDOGAPI_KEY=your_thedogapi_key
WEBSTER_KEY= WEBSTER_KEY=your_webster_key
XIAO_GITHUB_REPO_NAME= XIAO_GITHUB_REPO_NAME=xiao
XIAO_GITHUB_REPO_USERNAME= XIAO_GITHUB_REPO_USERNAME=xiaobotdev
+32
View File
@@ -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"]
+38
View File
@@ -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:
+36 -6
View File
@@ -7,6 +7,21 @@ const Registry = require('./Registry');
const Dispatcher = require('./Dispatcher'); const Dispatcher = require('./Dispatcher');
require('./Extensions'); 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 { module.exports = class CommandClient extends Client {
constructor(options) { constructor(options) {
super(options); super(options);
@@ -157,7 +172,11 @@ module.exports = class CommandClient extends Client {
} }
importBlacklist() { 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); const file = JSON.parse(read);
if (typeof file !== 'object' || Array.isArray(file)) return null; if (typeof file !== 'object' || Array.isArray(file)) return null;
if (!file.guild || !file.user) return null; if (!file.guild || !file.user) return null;
@@ -191,12 +210,17 @@ module.exports = class CommandClient extends Client {
} }
text += '\n ]\n}\n'; text += '\n ]\n}\n';
const buf = Buffer.from(text); 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; return buf;
} }
importCommandLeaderboard(add = false) { 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' encoding: 'utf8'
}); });
const file = JSON.parse(read); const file = JSON.parse(read);
@@ -221,14 +245,19 @@ module.exports = class CommandClient extends Client {
text = text.slice(0, -1); text = text.slice(0, -1);
text += '\n}\n'; text += '\n}\n';
const buf = Buffer.from(text); 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' encoding: 'utf8'
}); });
return buf; return buf;
} }
importLastRun() { 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' encoding: 'utf8'
}); });
const file = JSON.parse(read); const file = JSON.parse(read);
@@ -254,7 +283,8 @@ module.exports = class CommandClient extends Client {
text = text.slice(0, -1); text = text.slice(0, -1);
text += '\n}\n'; text += '\n}\n';
const buf = Buffer.from(text); 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' encoding: 'utf8'
}); });
return buf; return buf;
+30 -6
View File
@@ -8,6 +8,14 @@ const path = require('path');
const { checkFileExists } = require('../util/Util'); const { checkFileExists } = require('../util/Util');
const rounds = ['jeopardy_round', 'double_jeopardy_round', 'final_jeopardy_round']; 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 { module.exports = class JeopardyScrape {
constructor(client) { constructor(client) {
Object.defineProperty(this, 'client', { value: client }); Object.defineProperty(this, 'client', { value: client });
@@ -70,17 +78,17 @@ module.exports = class JeopardyScrape {
} }
async importData() { 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); const { seasons, gameIDs } = JSON.parse(read);
this.gameIDs = gameIDs; this.gameIDs = Array.isArray(gameIDs) ? gameIDs : [];
this.seasons = seasons; this.seasons = Array.isArray(seasons) ? seasons : [];
this.clues = await this.importClues(); this.clues = await this.importClues();
this.imported = true; this.imported = true;
return this; return this;
} }
importClues() { importClues() {
const pipeline = fs.createReadStream(path.join(__dirname, '..', 'jeopardy.json'), { encoding: 'utf8' }) const pipeline = fs.createReadStream(resolveStatePath('jeopardy.json'), { encoding: 'utf8' })
.pipe(parser()) .pipe(parser())
.pipe(pick({ filter: 'clues' })) .pipe(pick({ filter: 'clues' }))
.pipe(streamArray()); .pipe(streamArray());
@@ -96,19 +104,35 @@ module.exports = class JeopardyScrape {
gameIDs: this.gameIDs, gameIDs: this.gameIDs,
seasons: this.seasons 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; return buf;
} }
async checkForUpdates() { async checkForUpdates() {
if (!this.imported) { if (!this.imported) {
const fileExists = await checkFileExists(path.join(__dirname, '..', 'jeopardy.json')); const fileExists = await checkFileExists(resolveStatePath('jeopardy.json'));
if (fileExists) { if (fileExists) {
this.client.logger.info('[JEOPARDY] Importing from file...'); this.client.logger.info('[JEOPARDY] Importing from file...');
await this.importData(); await this.importData();
this.client.logger.info('[JEOPARDY] Import complete!'); 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 cluesBefore = this.clues.length;
const latestSeason = this.seasons[this.seasons.length - 1]; const latestSeason = this.seasons[this.seasons.length - 1];
const seasons = await this.fetchSeasons(); const seasons = await this.fetchSeasons();