diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9978f7e..6b07bc5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -62,9 +62,18 @@ jobs: CONTAINER_NAME="portfolio-app-dev" HEALTH_PORT="3001" IMAGE_NAME="${{ env.DOCKER_IMAGE }}:dev" + BOT_CONTAINER="portfolio-discord-bot-dev" + BOT_IMAGE="portfolio-discord-bot:dev" - # Check for existing container + # Build discord-bot image + echo "🏗️ Building discord-bot image..." + DOCKER_BUILDKIT=1 docker build \ + -t $BOT_IMAGE \ + ./discord-presence-bot + + # Check for existing containers EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") + EXISTING_BOT=$(docker ps -aq -f name=$BOT_CONTAINER || echo "") # Ensure networks exist echo "🌐 Ensuring networks exist..." @@ -78,13 +87,15 @@ jobs: echo "⚠️ Production database not reachable, app will use fallbacks" fi - # Stop and remove existing container - if [ ! -z "$EXISTING_CONTAINER" ]; then - echo "🛑 Stopping existing container..." - docker stop $EXISTING_CONTAINER 2>/dev/null || true - docker rm $EXISTING_CONTAINER 2>/dev/null || true - sleep 3 - fi + # Stop and remove existing containers + for C in $EXISTING_CONTAINER $EXISTING_BOT; do + if [ ! -z "$C" ]; then + echo "🛑 Stopping existing container $C..." + docker stop $C 2>/dev/null || true + docker rm $C 2>/dev/null || true + sleep 3 + fi + done # Ensure port is free PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->)" | awk '{print $1}' | head -1 || echo "") @@ -95,7 +106,18 @@ jobs: sleep 3 fi - # Start new container + # Start discord-bot container + echo "🤖 Starting discord-bot container..." + docker run -d \ + --name $BOT_CONTAINER \ + --restart unless-stopped \ + --network portfolio_net \ + -e DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN}" \ + -e DISCORD_USER_ID="${DISCORD_USER_ID:-172037532370862080}" \ + -e BOT_PORT=3001 \ + $BOT_IMAGE + + # Start new portfolio container echo "🆕 Starting new dev container..." docker run -d \ --name $CONTAINER_NAME \ @@ -159,6 +181,8 @@ jobs: N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }} DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN || '' }} + DISCORD_USER_ID: ${{ vars.DISCORD_USER_ID || '172037532370862080' }} - name: Cleanup run: docker image prune -f @@ -209,10 +233,12 @@ jobs: export ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}" export DIRECTUS_URL="${DIRECTUS_URL}" export DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}" + export DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN}" + export DISCORD_USER_ID="${DISCORD_USER_ID:-172037532370862080}" - # Start new container via compose - echo "🆕 Starting new production container..." - docker compose -f $COMPOSE_FILE up -d portfolio + # Start new containers via compose + echo "🆕 Starting new production containers..." + docker compose -f $COMPOSE_FILE up -d --build portfolio discord-bot # Wait for health echo "⏳ Waiting for container to be healthy..." @@ -274,6 +300,8 @@ jobs: N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }} DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN || '' }} + DISCORD_USER_ID: ${{ vars.DISCORD_USER_ID || '172037532370862080' }} - name: Cleanup run: docker image prune -f diff --git a/CLAUDE.md b/CLAUDE.md index 6fb365c..f7bce67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,8 @@ N8N_SECRET_TOKEN=... N8N_API_KEY=... DATABASE_URL=postgresql://... REDIS_URL=redis://... # optional +DISCORD_BOT_TOKEN=... # Discord bot token for presence bot (replaces Lanyard) +DISCORD_USER_ID=172037532370862080 # Discord user ID to track ``` ## Adding a CMS-managed Section diff --git a/app/components/ActivityFeed.tsx b/app/components/ActivityFeed.tsx index bb0fb27..5910a49 100644 --- a/app/components/ActivityFeed.tsx +++ b/app/components/ActivityFeed.tsx @@ -214,7 +214,12 @@ export default function ActivityFeed({ ))} -
+ {/* Subtle Spotify branding gradient */} diff --git a/discord-presence-bot/.gitignore b/discord-presence-bot/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/discord-presence-bot/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/discord-presence-bot/Dockerfile b/discord-presence-bot/Dockerfile new file mode 100644 index 0000000..afe4a07 --- /dev/null +++ b/discord-presence-bot/Dockerfile @@ -0,0 +1,17 @@ +FROM node:25-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --only=production && npm cache clean --force + +COPY index.js . + +USER node + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3001/presence || exit 1 + +CMD ["node", "index.js"] diff --git a/discord-presence-bot/index.js b/discord-presence-bot/index.js new file mode 100644 index 0000000..ee8d198 --- /dev/null +++ b/discord-presence-bot/index.js @@ -0,0 +1,110 @@ +const { Client, GatewayIntentBits, ActivityType } = require("discord.js"); +const http = require("http"); + +const TOKEN = process.env.DISCORD_BOT_TOKEN; +const TARGET_USER_ID = process.env.DISCORD_USER_ID || "172037532370862080"; +const PORT = parseInt(process.env.BOT_PORT || "3001", 10); + +if (!TOKEN) { + console.error("DISCORD_BOT_TOKEN is required"); + process.exit(1); +} + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildPresences, + ], +}); + +let cachedData = { + discord_status: "offline", + listening_to_spotify: false, + spotify: null, + activities: [], +}; + +function updatePresence(guild) { + const member = guild.members.cache.get(TARGET_USER_ID); + if (!member || !member.presence) return; + + const presence = member.presence; + cachedData.discord_status = presence.status || "offline"; + + cachedData.activities = presence.activities + ? presence.activities + .filter((a) => a.type !== ActivityType.Custom) + .map((a) => ({ + name: a.name, + type: a.type, + details: a.details || null, + state: a.state || null, + assets: a.assets + ? { + large_image: a.assets.largeImage || null, + large_text: a.assets.largeText || null, + small_image: a.assets.smallImage || null, + small_text: a.assets.smallText || null, + } + : null, + timestamps: a.timestamps + ? { + start: a.timestamps.start?.toISOString() || null, + end: a.timestamps.end?.toISOString() || null, + } + : null, + })) + : []; + + const spotifyActivity = presence.activities + ? presence.activities.find((a) => a.type === ActivityType.Listening && a.name === "Spotify") + : null; + + if (spotifyActivity && spotifyActivity.syncId) { + cachedData.listening_to_spotify = true; + cachedData.spotify = { + song: spotifyActivity.details || "", + artist: spotifyActivity.state ? spotifyActivity.state.replace(/; /g, "; ") : "", + album: spotifyActivity.assets?.largeText || "", + album_art_url: spotifyActivity.assets?.largeImage + ? `https://i.scdn.co/image/${spotifyActivity.assets.largeImage.replace("spotify:", "")}` + : null, + track_id: spotifyActivity.syncId || null, + }; + } else { + cachedData.listening_to_spotify = false; + cachedData.spotify = null; + } +} + +function updateAll() { + for (const guild of client.guilds.cache.values()) { + updatePresence(guild); + } +} + +client.on("ready", () => { + console.log(`Bot online as ${client.user.tag}`); + client.user.setActivity("Watching Presence", { type: ActivityType.Watching }); + updateAll(); +}); + +client.on("presenceUpdate", () => { + updateAll(); +}); + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/presence") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: cachedData })); + } else { + res.writeHead(404); + res.end("Not found"); + } +}); + +server.listen(PORT, () => { + console.log(`HTTP endpoint listening on port ${PORT}`); +}); + +client.login(TOKEN); diff --git a/discord-presence-bot/package-lock.json b/discord-presence-bot/package-lock.json new file mode 100644 index 0000000..aaffc97 --- /dev/null +++ b/discord-presence-bot/package-lock.json @@ -0,0 +1,324 @@ +{ + "name": "discord-presence-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-presence-bot", + "version": "1.0.0", + "dependencies": { + "discord.js": "^14.18.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/discord-presence-bot/package.json b/discord-presence-bot/package.json new file mode 100644 index 0000000..b70f9d9 --- /dev/null +++ b/discord-presence-bot/package.json @@ -0,0 +1,12 @@ +{ + "name": "discord-presence-bot", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "discord.js": "^14.18.0" + } +} diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 7875a09..a5a99b0 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -103,6 +103,33 @@ services: memory: 128M cpus: '0.1' + discord-bot: + build: + context: ./discord-presence-bot + dockerfile: Dockerfile + container_name: portfolio-discord-bot + restart: unless-stopped + environment: + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} + - DISCORD_USER_ID=${DISCORD_USER_ID:-172037532370862080} + - BOT_PORT=3001 + networks: + - portfolio_net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/presence"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + reservations: + memory: 64M + cpus: '0.1' + volumes: portfolio_data: driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 0f6e6ec..d9a1eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,33 @@ services: retries: 5 start_period: 30s + discord-bot: + build: + context: ./discord-presence-bot + dockerfile: Dockerfile + container_name: portfolio-discord-bot + restart: unless-stopped + environment: + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} + - DISCORD_USER_ID=${DISCORD_USER_ID:-172037532370862080} + - BOT_PORT=3001 + networks: + - portfolio_net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/presence"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + reservations: + memory: 64M + cpus: '0.1' + volumes: portfolio_data: driver: local diff --git a/env.example b/env.example index 1adce9e..c3a97c0 100644 --- a/env.example +++ b/env.example @@ -30,6 +30,10 @@ N8N_WEBHOOK_URL=https://n8n.dk0.dev N8N_SECRET_TOKEN=your-n8n-secret-token N8N_API_KEY=your-n8n-api-key +# Discord Presence Bot (replaces Lanyard) +DISCORD_BOT_TOKEN=your-discord-bot-token +DISCORD_USER_ID=172037532370862080 + # Directus CMS (for i18n messages & content pages) DIRECTUS_URL=https://cms.dk0.dev DIRECTUS_STATIC_TOKEN=your-static-token-here diff --git a/n8n-workflows/portfolio-status.json b/n8n-workflows/portfolio-status.json index dfb45dd..c26c0e9 100644 --- a/n8n-workflows/portfolio-status.json +++ b/n8n-workflows/portfolio-status.json @@ -93,7 +93,7 @@ }, { "parameters": { - "url": "https://api.lanyard.rest/v1/users/172037532370862080", + "url": "http://discord-bot:3001/presence", "options": {} }, "type": "n8n-nodes-base.httpRequest",