From 9f69b437045ba45e6332b15224f2d1152b155239 Mon Sep 17 00:00:00 2001 From: Shrev Dev Date: Sat, 11 Oct 2025 00:16:00 -0500 Subject: [PATCH] initial commit --- .dockerignore | 16 + .env.example | 15 + .gitignore | 9 + Dockerfile | 46 ++ FLY_DEPLOY.md | 184 ++++++++ LICENSE | 22 + QUICKSTART.md | 124 ++++++ README.md | 289 ++++++++++++ docker-compose.yml | 52 +++ fly.toml | 33 ++ package-lock.json | 640 +++++++++++++++++++++++++++ package.json | 31 ++ src/config/config.ts | 90 ++++ src/config/storage.ts | 217 +++++++++ src/index.ts | 141 ++++++ src/services/commands.service.ts | 652 ++++++++++++++++++++++++++++ src/services/discord.service.ts | 374 ++++++++++++++++ src/services/uptime-kuma.service.ts | 254 +++++++++++ src/types/uptime-kuma.ts | 53 +++ src/utils/logger.ts | 29 ++ tsconfig.json | 21 + 21 files changed, 3292 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 FLY_DEPLOY.md create mode 100644 LICENSE create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 fly.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config/config.ts create mode 100644 src/config/storage.ts create mode 100644 src/index.ts create mode 100644 src/services/commands.service.ts create mode 100644 src/services/discord.service.ts create mode 100644 src/services/uptime-kuma.service.ts create mode 100644 src/types/uptime-kuma.ts create mode 100644 src/utils/logger.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..512b8f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +dist +.env +.env.* +.git +.gitignore +*.log +*.md +README.md +QUICKSTART.md +docker-compose.yml +.dockerignore +Dockerfile +fly.toml +data +.github diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0075568 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Discord Bot Configuration +DISCORD_BOT_TOKEN=your_discord_bot_token_here + +# Uptime Kuma Configuration +UPTIME_KUMA_URL=http://localhost:3001 +UPTIME_KUMA_USERNAME=admin +UPTIME_KUMA_PASSWORD=your_password_here + +# Bot Configuration +UPDATE_INTERVAL=60 +EMBED_COLOR=5814783 + +# Admin Discord User IDs (comma-separated, can use slash commands) +ADMIN_USER_IDS= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4ef1e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +data/ +bot-config.json +.fly/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9c1a5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY src ./src + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production && \ + npm cache clean --force + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S appuser && \ + adduser -u 1001 -S appuser -G appuser + +# Change ownership +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Start the application +CMD ["node", "dist/index.js"] + diff --git a/FLY_DEPLOY.md b/FLY_DEPLOY.md new file mode 100644 index 0000000..7ac56b3 --- /dev/null +++ b/FLY_DEPLOY.md @@ -0,0 +1,184 @@ +# Deploy to Fly.io + +Deploy your Uptime Kuma Discord Bot to Fly.io with minimal resources (free tier compatible). + +## Prerequisites + +- Fly.io account (sign up at https://fly.io) +- Fly CLI installed (`brew install flyctl` or see https://fly.io/docs/hands-on/install-flyctl/) +- Discord bot token +- Uptime Kuma credentials + +## Quick Deploy + +### 1. Clone and Navigate + +```bash +git clone https://github.com/jakedev796/uptime-kuma-discord-bot +cd uptime-kuma-discord-bot +``` + +### 2. Login to Fly.io + +```bash +fly auth login +``` + +### 3. Deploy + +```bash +fly launch --copy-config --auto-confirm --ha=false --name your-unique-bot-name --now +``` + +This will: +- Create the app with minimal resources (256MB RAM, 1 shared CPU) +- Create a 1GB volume for persistent data +- Deploy the bot immediately + +### 4. Set Secrets + +```bash +fly secrets set \ + DISCORD_BOT_TOKEN=your_discord_bot_token_here \ + UPTIME_KUMA_URL=https://your-uptime-kuma-url.com \ + UPTIME_KUMA_USERNAME=admin \ + UPTIME_KUMA_PASSWORD=your_password_here \ + ADMIN_USER_IDS=your_discord_user_id +``` + +The bot will automatically restart with the new secrets. + +### 5. Verify Deployment + +```bash +fly logs # Check startup logs +fly status # Verify it's running +``` + +### 6. Configure in Discord + +``` +/set-channel #status +/set-title Production Services +/track-all +``` + +Done! Your bot is running on Fly.io! + +## Configuration + +The `fly.toml` is pre-configured for minimal resources: +- **CPU**: 1 shared CPU +- **Memory**: 256MB (minimal but sufficient for this bot) +- **Volume**: 1GB for persistent configuration +- **Region**: `iad` (Ashburn, VA - change if needed) +- **Always on**: `auto_stop_machines = false` and `min_machines_running = 1` +- **Single instance**: `ha = false` (no high availability needed) + +## Managing Your Deployment + +### View Logs + +```bash +fly logs +``` + +### Check Status + +```bash +fly status +``` + +### Scale Resources (if needed) + +```bash +fly scale memory 512 # Increase to 512MB +fly scale count 1 # Ensure single instance +``` + +### Update Secrets + +```bash +fly secrets set UPTIME_KUMA_PASSWORD=new_password +``` + +### Redeploy After Code Changes + +```bash +fly deploy +``` + +### SSH into the App + +```bash +fly ssh console +``` + +## Costs + +With this configuration: +- **Shared CPU-1x @ 256MB**: ~$1.94/month +- **1GB Volume**: Free (3GB included) +- **Bandwidth**: Free (100GB included) + +**Total**: ~$2/month or free with Fly.io credits + +## Regions + +Change the region in `fly.toml` if needed: +- `iad` - Ashburn, Virginia (US) +- `lhr` - London, UK +- `fra` - Frankfurt, Germany +- `syd` - Sydney, Australia +- `nrt` - Tokyo, Japan + +Full list: https://fly.io/docs/reference/regions/ + +## Troubleshooting + +### Bot not starting + +```bash +fly logs # Check for errors +fly status # Verify it's running +``` + +### Update environment variables + +```bash +fly secrets list # See all secrets +fly secrets set KEY=value +``` + +### Bot keeps restarting + +- Check memory usage: `fly scale show` +- Increase if needed: `fly scale memory 512` +- Check logs for errors: `fly logs` + +### Volume issues + +```bash +fly volumes list # List volumes +fly volumes delete bot_data # Delete if needed +fly volumes create bot_data --size 1 # Recreate +``` + +## Cleanup + +To delete the app: + +```bash +fly apps destroy your-app-name +``` + +## Support + +For Fly.io specific issues: +- Fly.io Docs: https://fly.io/docs/ +- Fly.io Community: https://community.fly.io/ + +For bot issues: +- Check [README.md](README.md) +- Open GitHub issue + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..660487a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..e9b53c3 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,124 @@ +# Quick Start Guide + +**Want to deploy to Fly.io instead?** See [FLY_DEPLOY.md](FLY_DEPLOY.md) for one-click deployment. + +## Prerequisites + +- Docker installed (or Node.js 18+) +- Discord bot created +- Uptime Kuma instance running + +## Step 1: Create Discord Bot (2 minutes) + +1. Go to https://discord.com/developers/applications +2. Click **"New Application"** and name it +3. Go to **"Bot"** tab, click **"Add Bot"**, then **"Reset Token"** and copy it +4. Go to **"OAuth2" → "URL Generator"** +5. Select scopes: `bot` + `applications.commands` +6. Select permissions: `Send Messages`, `Embed Links`, `Read Message History`, `Use Slash Commands` +7. Open the generated URL to invite bot to your server + +## Step 2: Setup Bot (2 minutes) + +```bash +git clone https://github.com/jakedev796/uptime-kuma-discord-bot +cd uptime-kuma-discord-bot +cp .env.example .env +``` + +**Edit `.env`** (only 3 required): +```env +DISCORD_BOT_TOKEN=your_discord_bot_token +UPTIME_KUMA_URL=http://uptime-kuma:3001 +UPTIME_KUMA_USERNAME=admin +UPTIME_KUMA_PASSWORD=your_password +ADMIN_USER_IDS=your_discord_user_id +``` + +**Start it:** +```bash +docker-compose up -d +# or: npm install && npm run build && npm start +``` + +## Step 3: Configure in Discord (1 minute) + +Type these commands in Discord: + +``` +/set-channel #status # Pick channel from dropdown +/set-title Production Services # Custom title (optional) +/track # Type to search, select monitor +/track # Repeat for more monitors! +# Or just: +/track-all # Track everything +``` + +Done! + +## What's Next? + +### Organize with Groups + +```bash +/group-create Media Servers # Create a group +/group-add-monitor # Group dropdown, then monitor dropdown + → Select: Your Group Name + → Type: Your Monitor Name + → Repeat for more monitors + +/groups # See your organization +/config # See full status +``` + +### Quick Operations + +```bash +/track # Type monitor name, select from dropdown +/untrack # Type monitor name, remove it +/group-remove-monitor # Remove monitor from its group +/group-delete # Delete entire group +``` + +## Troubleshooting + +### Commands don't appear +- Wait 5-10 minutes +- Kick and re-invite bot +- Restart Discord + +### Bot won't connect +- Check `.env` credentials +- View logs: `docker-compose logs -f` +- Try `/config` in Discord + +### Permission denied +- Add your Discord user ID to `ADMIN_USER_IDS` in `.env` +- Get ID: Right-click username → Copy ID +- Restart bot + +## Commands Cheat Sheet + +| Command | What it does | +|---------|--------------| +| `/set-channel` | Choose status channel | +| `/set-title` | Customize embed title | +| `/track` | Add monitor (autocomplete!) | +| `/untrack` | Remove monitor (autocomplete!) | +| `/track-all` | Track everything | +| `/group-create` | Create new group | +| `/group-delete` | Delete group (autocomplete!) | +| `/group-add-monitor` | Add to group (both autocomplete!) | +| `/group-remove-monitor` | Remove from group (autocomplete!) | +| `/groups` | List all groups | +| `/config` | See full status | + +## Pro Tips + +- **Create groups first**, then assign monitors +- **Use `/config`** to see everything at a glance +- **Start with `/track-all`** then organize into groups + +--- + +**Need help?** Check [README.md](README.md) or open a GitHub issue. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd5beb5 --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# Uptime Kuma Discord Bot + +A comprehensive Discord bot that integrates with [Uptime Kuma](https://github.com/louislam/uptime-kuma) to display real-time monitor status information in Discord channels. + +## Features + +- **Real-time Monitoring**: Socket.io connection with automatic updates +- **Slash Commands**: Configure everything directly from Discord with autocomplete +- **Rich Embeds**: Simple embeds with status indicators (green/red/yellow/blue circles) +- **Monitor Grouping**: Organize monitors into custom sections (Media, Gaming, etc.) +- **Persistent Configuration**: All settings saved automatically +- **Docker Support**: Full Docker and Docker Compose support +- **Fly.io Ready**: One-click deployment to Fly.io (free tier compatible) +- **Admin Controls**: Restrict commands to specific users +- **Auto-Reconnection**: Handles disconnections gracefully + +## Commands + +All configuration via simple, autocomplete-powered slash commands: + +### Setup Commands +| Command | Description | +|---------|-------------| +| `/set-channel ` | Set where to post status updates | +| `/set-title ` | Set the embed title (replaces "Uptime Kuma Status") | +| `/config` | Show full bot configuration and status | + +### Monitor Commands +| Command | Description | +|---------|-------------| +| `/track <monitor>` | Add a monitor to tracking (autocomplete) | +| `/untrack <monitor>` | Remove a monitor from tracking (autocomplete) | +| `/track-all` | Track all available monitors | + +### Group Commands +| Command | Description | +|---------|-------------| +| `/group-create <name>` | Create a new group (e.g., "Media Servers") | +| `/group-delete <group>` | Delete a group (autocomplete) | +| `/group-add-monitor <group> <monitor>` | Add monitor to group (both autocomplete) | +| `/group-remove-monitor <monitor>` | Remove monitor from its group (autocomplete) | +| `/groups` | List all groups and their monitors | + +## Quick Start + +### Deploy to Fly.io (Recommended) + +See **[FLY_DEPLOY.md](FLY_DEPLOY.md)** for full instructions. + +Super quick version: + +```bash +fly launch --copy-config --auto-confirm --ha=false --name my-bot --now +fly secrets set DISCORD_BOT_TOKEN=xxx UPTIME_KUMA_URL=xxx UPTIME_KUMA_USERNAME=xxx UPTIME_KUMA_PASSWORD=xxx +# Then use /set-channel in Discord +``` + +### Self-Host with Docker + +See **[QUICKSTART.md](QUICKSTART.md)** for detailed setup instructions. + +**TL;DR:** +1. Create Discord bot and get token +2. Configure `.env` with bot token and Uptime Kuma credentials +3. Run: `docker-compose up -d` or `npm start` +4. Use `/set-channel` and `/track-all` in Discord + +## Usage + +### Basic Setup + +```bash +/set-channel #status # Set status channel +/set-title My Services # Customize title (optional) +/track-all # Track all monitors +/config # Verify setup +``` + +### Organizing with Groups + +```bash +/group-create Media Servers # Create group +/group-add-monitor # Assign monitors (autocomplete) +/groups # View organization +``` + +For detailed usage examples, see [QUICKSTART.md](QUICKSTART.md). + +## Embed Preview + +With groups configured, your embed looks like this: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Production Services + +Overall Status: 87.5% Operational + +🟢 Online: 7 +🔴 Offline: 1 +🟡 Pending: 0 +🔵 Maintenance: 0 + +━━━━━━━━━━━━━━━━━━━━ +Media Servers +🟢 Plex - UP +🟢 Jellyfin - UP + +━━━━━━━━━━━━━━━━━━━━ +Gaming Servers +🔴 Minecraft - DOWN +🟢 Valheim - UP + +━━━━━━━━━━━━━━━━━━━━ +Other Services +🟢 Website - UP + +Last updated: 2:30 PM +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `DISCORD_BOT_TOKEN` | Your Discord bot token | Yes | - | +| `UPTIME_KUMA_URL` | URL of your Uptime Kuma instance | Yes | `http://localhost:3001` | +| `UPTIME_KUMA_USERNAME` | Uptime Kuma username | Yes | - | +| `UPTIME_KUMA_PASSWORD` | Uptime Kuma password | Yes | - | +| `UPDATE_INTERVAL` | Update interval in seconds | No | `60` | +| `EMBED_COLOR` | Decimal color code | No | `5814783` | +| `ADMIN_USER_IDS` | Comma-separated Discord user IDs | No | `` (all users) | + +### Setting Up Discord Bot + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Click "New Application" and name it +3. Go to "Bot" section, click "Add Bot", then "Reset Token" and copy it +4. Go to "OAuth2" then "URL Generator" +5. Select scopes: `bot` and `applications.commands` +6. Select permissions: `Send Messages`, `Embed Links`, `Read Message History`, `Use Slash Commands` +7. Open the generated URL to invite the bot to your server + +### Getting Your Discord User ID (For Admin Access) + +1. Enable Developer Mode (User Settings → Advanced → Developer Mode) +2. Right-click your username and select "Copy ID" +3. Add to `.env`: `ADMIN_USER_IDS=your_user_id` + +## Advanced Features + +### Monitor Groups + +Create organized sections in your embed: +- **Media Servers**: Plex, Jellyfin, etc. +- **Gaming**: Minecraft, Valheim, etc. +- **Infrastructure**: Nginx, databases, etc. +- **Other Services**: Ungrouped monitors appear here + +Monitors can only be in ONE group at a time. Assigning to a new group automatically removes from the old one. + + +## Docker Configuration + +### Docker Network (Same Host as Uptime Kuma) + +```bash +# Create network +docker network create uptime-kuma-network + +# Connect Uptime Kuma +docker network connect uptime-kuma-network uptime-kuma + +# Update docker-compose.yml (uncomment networks section) +# Update .env: +UPTIME_KUMA_URL=http://uptime-kuma:3001 +``` + +### Persistent Data + +Configuration saved to Docker volume `bot-data`: +- Channel ID +- Message IDs (for reuse) +- Tracked monitors +- Groups and assignments +- Custom title + +## Security + +1. **Secure `.env`**: Never commit to version control +2. **Restrict admins**: Set `ADMIN_USER_IDS` to specific users +3. **Dedicated account**: Create a bot-specific Uptime Kuma user +4. **Minimal permissions**: Only grant necessary Discord permissions + +## Troubleshooting + +### Bot doesn't post updates +- Use `/set-channel` to set a channel +- Use `/config` to verify setup +- Check bot permissions in that channel + +### Commands don't appear +- Wait 5-10 minutes (Discord caches) +- Try kicking and re-inviting bot +- Restart Discord client + +### "Permission denied" on commands +- Add your user ID to `ADMIN_USER_IDS` +- Restart bot +- Get ID: Right-click username → Copy ID + +### Autocomplete doesn't show monitors +- Ensure bot is connected to Uptime Kuma +- Check `/config` for connection status +- View logs: `docker-compose logs -f` + +### Bot creates new messages after restart +- This is fixed! Bot now reuses messages +- Message IDs saved to `data/bot-config.json` +- Check logs for message handling status + +## Documentation Files + +- **[README.md](README.md)** - This file +- **[QUICKSTART.md](QUICKSTART.md)** - Self-hosting setup guide +- **[FLY_DEPLOY.md](FLY_DEPLOY.md)** - Deploy to Fly.io (recommended) + +## Tips + +- Use `/config` to see everything at a glance +- Create groups BEFORE assigning monitors to them +- Use `/groups` to see your current organization +- `/track-all` then `/untrack` unwanted monitors is often faster + +## Development + +```bash +npm install +npm run dev # Development mode with ts-node +``` + +## Project Structure + +``` +uptime-kuma-discord-bot/ +├── src/ +│ ├── config/ +│ │ ├── config.ts # Environment configuration +│ │ └── storage.ts # Persistent configuration storage +│ ├── services/ +│ │ ├── commands.service.ts # Discord slash commands with autocomplete +│ │ ├── discord.service.ts # Discord bot service +│ │ └── uptime-kuma.service.ts # Uptime Kuma Socket.io integration +│ ├── types/ +│ │ └── uptime-kuma.ts # TypeScript type definitions +│ ├── utils/ +│ │ └── logger.ts # Logging utility +│ └── index.ts # Application entry point +├── data/ # Persistent configuration (auto-created) +│ └── bot-config.json # Stored settings and monitor groups +├── docker-compose.yml # Docker Compose configuration +├── Dockerfile # Docker image definition +├── package.json # Dependencies +└── tsconfig.json # TypeScript configuration +``` + +## Why Not Use Uptime Kuma API Keys? + +Uptime Kuma's [API Keys feature](https://github.com/louislam/uptime-kuma/wiki/API-Keys) (version >= 1.21.0) is designed for REST endpoints like Prometheus metrics, not Socket.io connections. This bot uses Socket.io for real-time monitor updates, which requires username/password authentication. API Keys are not supported for Socket.io authentication in Uptime Kuma. + +## License + +MIT License - see [LICENSE](LICENSE) + +## Resources + +- [Uptime Kuma](https://github.com/louislam/uptime-kuma) +- [Discord.js](https://discord.js.org/) +- [Socket.io](https://socket.io/) + +## Support + +If you encounter issues: +1. Check the logs: `docker-compose logs -f` +2. Verify your `.env` configuration +3. Try `/config` command to check connection status +4. Open an issue on GitHub with details diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c4aa12 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + uptime-kuma-discord-bot: + build: + context: . + dockerfile: Dockerfile + container_name: uptime-kuma-discord-bot + restart: unless-stopped + environment: + # Discord Configuration + DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN} + + # Uptime Kuma Configuration + UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-http://localhost:3001} + UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME} + UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD} + + # Bot Configuration + UPDATE_INTERVAL: ${UPDATE_INTERVAL:-60} + EMBED_COLOR: ${EMBED_COLOR:-5814783} + + # Admin User IDs (comma-separated Discord user IDs) + ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} + + # Data directory for persistent configuration + DATA_DIR: /app/data + + # Persistent volume for bot configuration + volumes: + - bot-data:/app/data + + # If Uptime Kuma is running in the same Docker network + # networks: + # - uptime-kuma-network + + # Logging configuration + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + bot-data: + driver: local + +# Uncomment if using Docker network +# networks: +# uptime-kuma-network: +# external: true + diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..8e5de4e --- /dev/null +++ b/fly.toml @@ -0,0 +1,33 @@ +# fly.toml app configuration file generated for boker-discord-uptime-kuma on 2025-10-11T00:11:47-05:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'boker-discord-uptime-kuma' +primary_region = 'iad' + +[build] + dockerfile = 'Dockerfile' + +[deploy] + strategy = 'immediate' + +[env] + DATA_DIR = '/app/data' + EMBED_COLOR = '5814783' + UPDATE_INTERVAL = '60' + +[[mounts]] + source = 'bot_data' + destination = '/app/data' + +[http_service] + internal_port = 8080 + auto_stop_machines = 'off' + auto_start_machines = false + min_machines_running = 1 + +[[vm]] + cpu_kind = 'shared' + cpus = 1 + memory_mb = 256 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d3c17d5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,640 @@ +{ + "name": "uptime-kuma-discord-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uptime-kuma-discord-bot", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "discord.js": "^14.14.1", + "dotenv": "^16.3.1", + "socket.io-client": "^4.7.2" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.12.2.tgz", + "integrity": "sha512-AugKmrgRJOHXEyMkABH/hXpAmIBKbYokCEl9VAM4Kh6FvkdobQ+Zhv7AR6K+y5hS7+VQ7gKXPYCe1JQmV07H1g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.1", + "@discordjs/util": "^1.1.1", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.26", + "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.1", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", + "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.1" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "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/util": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "license": "Apache-2.0", + "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/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "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/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz", + "integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.29", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.29.tgz", + "integrity": "sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.23.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.23.2.tgz", + "integrity": "sha512-tU2NFr823X3TXEc8KyR/4m296KLxPai4nirN3q9kHCpY4TKj96n9lHZnyLzRNMui8EbL07jg9hgH2PWWfKMGIg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.12.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.1", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.1.1", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.29", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "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 + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "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.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "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.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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 + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c425641 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "uptime-kuma-discord-bot", + "version": "1.0.0", + "description": "Discord bot for monitoring Uptime Kuma service status", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "watch": "tsc --watch" + }, + "keywords": [ + "discord", + "bot", + "uptime-kuma", + "monitoring" + ], + "author": "", + "license": "MIT", + "dependencies": { + "discord.js": "^14.14.1", + "socket.io-client": "^4.7.2", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} + diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..78f442c --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,90 @@ +import { config as loadEnv } from 'dotenv'; +import { existsSync } from 'fs'; + +loadEnv(); + +export interface Config { + discord: { + token: string; + adminUserIds: string[]; + }; + uptimeKuma: { + url: string; + username: string; + password: string; + }; + bot: { + updateInterval: number; + embedColor: number; + }; +} + +class ConfigManager { + private config: Config; + + constructor() { + this.config = this.loadConfig(); + this.validateConfig(); + } + + private loadConfig(): Config { + return { + discord: { + token: process.env.DISCORD_BOT_TOKEN || '', + adminUserIds: this.parseAdminUserIds(process.env.ADMIN_USER_IDS), + }, + uptimeKuma: { + url: process.env.UPTIME_KUMA_URL || 'http://localhost:3001', + username: process.env.UPTIME_KUMA_USERNAME || '', + password: process.env.UPTIME_KUMA_PASSWORD || '', + }, + bot: { + updateInterval: parseInt(process.env.UPDATE_INTERVAL || '60', 10) * 1000, + embedColor: parseInt(process.env.EMBED_COLOR || '5814783', 10), + }, + }; + } + + private parseAdminUserIds(ids: string | undefined): string[] { + if (!ids || ids.trim() === '') { + return []; + } + return ids.split(',').map(id => id.trim()).filter(id => id.length > 0); + } + + private validateConfig(): void { + const errors: string[] = []; + + if (!this.config.discord.token) { + errors.push('DISCORD_BOT_TOKEN is required'); + } + + if (!this.config.uptimeKuma.url) { + errors.push('UPTIME_KUMA_URL is required'); + } + + if (!this.config.uptimeKuma.username) { + errors.push('UPTIME_KUMA_USERNAME is required'); + } + + if (!this.config.uptimeKuma.password) { + errors.push('UPTIME_KUMA_PASSWORD is required'); + } + + if (this.config.bot.updateInterval < 10000) { + errors.push('UPDATE_INTERVAL must be at least 10 seconds'); + } + + if (errors.length > 0) { + throw new Error(`Configuration validation failed:\n${errors.join('\n')}`); + } + } + + public getConfig(): Config { + return this.config; + } +} + +export const configManager = new ConfigManager(); +export const config = configManager.getConfig(); + diff --git a/src/config/storage.ts b/src/config/storage.ts new file mode 100644 index 0000000..1d1152d --- /dev/null +++ b/src/config/storage.ts @@ -0,0 +1,217 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { Logger } from '../utils/logger'; + +export interface MonitorGroup { + name: string; + monitorIds: number[]; +} + +export interface BotConfig { + channelId: string | null; + messageIds: string[]; + monitorIds: number[]; + groups: MonitorGroup[]; + updateInterval: number; + embedColor: number; + statusMessage: string; +} + +export class ConfigStorage { + private configPath: string; + private config: BotConfig; + private logger: Logger; + + constructor() { + this.logger = new Logger('ConfigStorage'); + const dataDir = process.env.DATA_DIR || './data'; + + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + this.configPath = join(dataDir, 'bot-config.json'); + this.config = this.load(); + } + + private load(): BotConfig { + if (existsSync(this.configPath)) { + try { + const data = readFileSync(this.configPath, 'utf-8'); + const loaded = JSON.parse(data); + this.logger.info('Loaded configuration from storage'); + return { + channelId: loaded.channelId || null, + messageIds: loaded.messageIds || [], + monitorIds: loaded.monitorIds || [], + groups: loaded.groups || [], + updateInterval: loaded.updateInterval || parseInt(process.env.UPDATE_INTERVAL || '60', 10) * 1000, + embedColor: loaded.embedColor || parseInt(process.env.EMBED_COLOR || '5814783', 10), + statusMessage: loaded.statusMessage || 'Service Status', + }; + } catch (error: any) { + this.logger.error(`Failed to load config: ${error.message}`); + } + } + + return { + channelId: null, + messageIds: [], + monitorIds: [], + groups: [], + updateInterval: parseInt(process.env.UPDATE_INTERVAL || '60', 10) * 1000, + embedColor: parseInt(process.env.EMBED_COLOR || '5814783', 10), + statusMessage: 'Service Status', + }; + } + + private save(): void { + try { + writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8'); + this.logger.info('Saved configuration to storage'); + } catch (error: any) { + this.logger.error(`Failed to save config: ${error.message}`); + } + } + + public getMonitorIds(): number[] { + return [...this.config.monitorIds]; + } + + public setMonitorIds(ids: number[]): void { + this.config.monitorIds = ids; + this.save(); + } + + public addMonitor(id: number): boolean { + if (!this.config.monitorIds.includes(id)) { + this.config.monitorIds.push(id); + this.save(); + return true; + } + return false; + } + + public removeMonitor(id: number): boolean { + const index = this.config.monitorIds.indexOf(id); + if (index > -1) { + this.config.monitorIds.splice(index, 1); + this.save(); + return true; + } + return false; + } + + public clearMonitors(): void { + this.config.monitorIds = []; + this.save(); + } + + public getUpdateInterval(): number { + return this.config.updateInterval; + } + + public setUpdateInterval(interval: number): void { + if (interval >= 10000) { + this.config.updateInterval = interval; + this.save(); + } + } + + public getEmbedColor(): number { + return this.config.embedColor; + } + + public setEmbedColor(color: number): void { + this.config.embedColor = color; + this.save(); + } + + public getConfig(): BotConfig { + return { ...this.config }; + } + + public getChannelId(): string | null { + return this.config.channelId; + } + + public setChannelId(channelId: string): void { + this.config.channelId = channelId; + this.save(); + } + + public getStatusMessage(): string { + return this.config.statusMessage; + } + + public setStatusMessage(message: string): void { + this.config.statusMessage = message; + this.save(); + } + + public getMessageIds(): string[] { + return [...this.config.messageIds]; + } + + public setMessageIds(ids: string[]): void { + this.config.messageIds = ids; + this.save(); + } + + public getGroups(): MonitorGroup[] { + return [...this.config.groups]; + } + + public addGroup(name: string): boolean { + if (this.config.groups.some(g => g.name.toLowerCase() === name.toLowerCase())) { + return false; + } + this.config.groups.push({ name, monitorIds: [] }); + this.save(); + return true; + } + + public removeGroup(name: string): boolean { + const index = this.config.groups.findIndex(g => g.name.toLowerCase() === name.toLowerCase()); + if (index > -1) { + this.config.groups.splice(index, 1); + this.save(); + return true; + } + return false; + } + + public addMonitorToGroup(groupName: string, monitorId: number): boolean { + const group = this.config.groups.find(g => g.name.toLowerCase() === groupName.toLowerCase()); + if (group && !group.monitorIds.includes(monitorId)) { + for (const g of this.config.groups) { + const idx = g.monitorIds.indexOf(monitorId); + if (idx > -1) { + g.monitorIds.splice(idx, 1); + } + } + group.monitorIds.push(monitorId); + this.save(); + return true; + } + return false; + } + + public removeMonitorFromGroup(monitorId: number): boolean { + let found = false; + for (const group of this.config.groups) { + const index = group.monitorIds.indexOf(monitorId); + if (index > -1) { + group.monitorIds.splice(index, 1); + found = true; + } + } + if (found) { + this.save(); + } + return found; + } +} + +export const configStorage = new ConfigStorage(); + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..444fd98 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,141 @@ +import { config } from './config/config'; +import { configStorage } from './config/storage'; +import { UptimeKumaService } from './services/uptime-kuma.service'; +import { DiscordService } from './services/discord.service'; +import { Logger } from './utils/logger'; + +class UptimeKumaDiscordBot { + private uptimeKuma: UptimeKumaService; + private discord: DiscordService; + private updateInterval: NodeJS.Timeout | null = null; + private logger: Logger; + private isShuttingDown = false; + + constructor() { + this.uptimeKuma = new UptimeKumaService(); + this.discord = new DiscordService(); + this.discord.setUptimeKumaService(this.uptimeKuma); + this.logger = new Logger('Bot'); + this.setupSignalHandlers(); + } + + private setupSignalHandlers(): void { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGQUIT']; + + signals.forEach(signal => { + process.on(signal, () => { + this.logger.info(`Received ${signal}, shutting down gracefully...`); + this.shutdown(); + }); + }); + + process.on('uncaughtException', (error: Error) => { + this.logger.error(`Uncaught Exception: ${error.message}`); + this.logger.error(error.stack || ''); + this.shutdown(); + }); + + process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { + this.logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`); + this.shutdown(); + }); + } + + public async start(): Promise<void> { + try { + this.logger.info('Starting Uptime Kuma Discord Bot...'); + + this.logger.info('Connecting to Discord...'); + await this.discord.connect(); + + this.logger.info('Connecting to Uptime Kuma...'); + await this.uptimeKuma.connect(); + + this.setupEventListeners(); + + this.startUpdateInterval(); + + this.logger.info('Bot started successfully!'); + } catch (error: any) { + this.logger.error(`Failed to start bot: ${error.message}`); + this.logger.error(error.stack || ''); + process.exit(1); + } + } + + private setupEventListeners(): void { + this.uptimeKuma.on('monitorsUpdated', (monitors: any) => { + this.discord.updateMonitorStatus(monitors).catch((error: Error) => { + this.logger.error(`Failed to update Discord status: ${error.message}`); + }); + }); + + this.uptimeKuma.on('statusChanged', (stats: any) => { + this.logger.info(`Monitor status changed: ${stats.monitor.name} is now ${stats.currentStatus}`); + }); + + this.uptimeKuma.on('disconnected', (reason: string) => { + this.logger.warn(`Uptime Kuma disconnected: ${reason}`); + if (reason === 'io server disconnect') { + this.logger.info('Attempting to reconnect...'); + } + }); + } + + private startUpdateInterval(): void { + const updateFn = () => { + if (!this.uptimeKuma.isConnected()) { + this.logger.warn('Uptime Kuma is not connected, skipping update'); + return; + } + + const trackedIds = configStorage.getMonitorIds(); + const monitors = this.uptimeKuma.getMonitorStats(trackedIds); + this.discord.updateMonitorStatus(monitors).catch(error => { + this.logger.error(`Failed to update Discord status: ${error.message}`); + }); + }; + + const interval = configStorage.getUpdateInterval(); + this.updateInterval = setInterval(updateFn, interval); + + this.logger.info(`Update interval set to ${interval / 1000} seconds`); + + const trackedIds = configStorage.getMonitorIds(); + if (trackedIds.length > 0) { + this.logger.info(`Tracking ${trackedIds.length} specific monitor(s): ${trackedIds.join(', ')}`); + } else { + this.logger.info('Tracking all monitors'); + } + } + + private async shutdown(): Promise<void> { + if (this.isShuttingDown) { + return; + } + + this.isShuttingDown = true; + + try { + this.logger.info('Shutting down...'); + + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + + this.uptimeKuma.disconnect(); + this.discord.disconnect(); + + this.logger.info('Shutdown complete'); + process.exit(0); + } catch (error: any) { + this.logger.error(`Error during shutdown: ${error.message}`); + process.exit(1); + } + } +} + +const bot = new UptimeKumaDiscordBot(); +bot.start(); + diff --git a/src/services/commands.service.ts b/src/services/commands.service.ts new file mode 100644 index 0000000..24d4dbe --- /dev/null +++ b/src/services/commands.service.ts @@ -0,0 +1,652 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + AutocompleteInteraction, + EmbedBuilder, + PermissionFlagsBits, + SlashCommandOptionsOnlyBuilder, + MessageFlags, +} from 'discord.js'; +import { config } from '../config/config'; +import { configStorage } from '../config/storage'; +import { UptimeKumaService } from './uptime-kuma.service'; +import { Logger } from '../utils/logger'; + +export interface Command { + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; + execute: (interaction: ChatInputCommandInteraction, uptimeKuma: UptimeKumaService) => Promise<void>; +} + +export class CommandsService { + private logger: Logger; + private commands: Map<string, Command>; + + constructor() { + this.logger = new Logger('CommandsService'); + this.commands = new Map(); + this.registerCommands(); + } + + private registerCommands(): void { + const commands: Command[] = [ + { + data: new SlashCommandBuilder() + .setName('track') + .setDescription('Add a monitor to tracking') + .addStringOption(option => + option + .setName('monitor') + .setDescription('Select monitor to track') + .setRequired(true) + .setAutocomplete(true) + ), + execute: this.addMonitor.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('untrack') + .setDescription('Remove a monitor from tracking') + .addStringOption(option => + option + .setName('monitor') + .setDescription('Select monitor to untrack') + .setRequired(true) + .setAutocomplete(true) + ), + execute: this.removeMonitor.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('track-all') + .setDescription('Track all available monitors'), + execute: this.trackAll.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('group-create') + .setDescription('Create a new monitor group') + .addStringOption(option => + option + .setName('name') + .setDescription('Name of the group (e.g., "Media Servers")') + .setRequired(true) + .setMaxLength(50) + ), + execute: this.addGroup.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('group-delete') + .setDescription('Delete a monitor group') + .addStringOption(option => + option + .setName('group') + .setDescription('Select group to delete') + .setRequired(true) + .setAutocomplete(true) + ), + execute: this.removeGroup.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('group-add-monitor') + .setDescription('Add a monitor to a group') + .addStringOption(option => + option + .setName('group') + .setDescription('Select group') + .setRequired(true) + .setAutocomplete(true) + ) + .addStringOption(option => + option + .setName('monitor') + .setDescription('Select monitor') + .setRequired(true) + .setAutocomplete(true) + ), + execute: this.assignMonitorToGroup.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('group-remove-monitor') + .setDescription('Remove a monitor from its group') + .addStringOption(option => + option + .setName('monitor') + .setDescription('Select monitor to unassign') + .setRequired(true) + .setAutocomplete(true) + ), + execute: this.unassignMonitor.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('groups') + .setDescription('List all groups and monitors'), + execute: this.listGroups.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('set-channel') + .setDescription('Set the status update channel') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The channel to post status updates in') + .setRequired(true) + ), + execute: this.setChannel.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('set-title') + .setDescription('Set the embed title') + .addStringOption(option => + option + .setName('title') + .setDescription('Embed title (e.g., "Production Services")') + .setRequired(true) + .setMaxLength(100) + ), + execute: this.setMessage.bind(this), + }, + { + data: new SlashCommandBuilder() + .setName('config') + .setDescription('Show current bot configuration'), + execute: this.showConfig.bind(this), + }, + ]; + + for (const command of commands) { + this.commands.set(command.data.name, command); + } + + this.logger.info(`Registered ${this.commands.size} slash commands`); + } + + private isAdmin(userId: string): boolean { + if (config.discord.adminUserIds.length === 0) { + return true; + } + return config.discord.adminUserIds.includes(userId); + } + + private async checkAdmin(interaction: ChatInputCommandInteraction): Promise<boolean> { + if (!this.isAdmin(interaction.user.id)) { + await interaction.reply({ + content: '❌ You do not have permission to use this command.', + flags: MessageFlags.Ephemeral, + }); + return false; + } + return true; + } + + + private async addMonitor( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const monitorIdStr = interaction.options.getString('monitor', true); + const monitorId = parseInt(monitorIdStr, 10); + + const currentIds = configStorage.getMonitorIds(); + if (currentIds.includes(monitorId)) { + await interaction.reply({ + content: '⚠️ This monitor is already being tracked.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const newIds = [...currentIds, monitorId]; + configStorage.setMonitorIds(newIds); + + const monitors = uptimeKuma.getAllMonitors(); + const monitorName = monitors.get(monitorId)?.name || `ID ${monitorId}`; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Monitor Added') + .setDescription(`Now tracking: **${monitorName}**\n\nTotal tracked: ${newIds.length} monitors`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + private async removeMonitor( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const monitorIdStr = interaction.options.getString('monitor', true); + const monitorId = parseInt(monitorIdStr, 10); + + const currentIds = configStorage.getMonitorIds(); + const newIds = currentIds.filter(id => id !== monitorId); + + if (currentIds.length === newIds.length) { + await interaction.reply({ + content: '⚠️ This monitor was not being tracked.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + configStorage.setMonitorIds(newIds); + + const monitors = uptimeKuma.getAllMonitors(); + const monitorName = monitors.get(monitorId)?.name || `ID ${monitorId}`; + + const embed = new EmbedBuilder() + .setColor(0xff9900) + .setTitle('🗑️ Monitor Removed') + .setDescription(`Stopped tracking: **${monitorName}**\n\nTotal tracked: ${newIds.length || 'All monitors'}`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + private async trackAll( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + configStorage.clearMonitors(); + + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('🌐 Tracking All Monitors') + .setDescription('Now tracking all available monitors from Uptime Kuma') + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + + + private async setChannel( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const channel = interaction.options.getChannel('channel', true); + + try { + const discordService = (interaction.client as any).discordService; + if (discordService) { + await discordService.setChannel(channel.id); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Status Channel Updated') + .setDescription(`Status updates will now be posted in <#${channel.id}>`) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } else { + throw new Error('Discord service not available'); + } + } catch (error: any) { + await interaction.editReply({ + content: `❌ Failed to set channel: ${error.message}`, + }); + } + } + + private async setMessage( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const message = interaction.options.getString('message', true); + + configStorage.setStatusMessage(message); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Status Message Updated') + .setDescription(`Status message set to: **${message}**\n\nThis will appear in the embed title on the next update.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + private async showConfig( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const botConfig = configStorage.getConfig(); + const trackedIds = botConfig.monitorIds; + const channelId = botConfig.channelId; + const groups = configStorage.getGroups(); + const monitors = uptimeKuma.getAllMonitors(); + + const embed = new EmbedBuilder() + .setColor(configStorage.getEmbedColor()) + .setTitle('⚙️ Bot Configuration & Status') + .addFields( + { + name: '📢 Status Channel', + value: channelId ? `<#${channelId}>` : '⚠️ Not set - use `/set-channel`', + inline: false, + }, + { + name: '📝 Embed Title', + value: botConfig.statusMessage, + inline: true, + }, + { + name: '⏱️ Update Interval', + value: `${botConfig.updateInterval / 1000}s`, + inline: true, + }, + { + name: '🔌 Uptime Kuma', + value: uptimeKuma.isConnected() ? '🟢 Connected' : '🔴 Disconnected', + inline: true, + }, + { + name: '📊 Total Monitors', + value: `${monitors.size} available`, + inline: true, + }, + { + name: '🎯 Tracking', + value: trackedIds.length === 0 ? 'All monitors' : `${trackedIds.length} monitors`, + inline: true, + }, + { + name: '📁 Groups', + value: groups.length === 0 ? 'None' : `${groups.length} groups`, + inline: true, + } + ); + + if (trackedIds.length > 0 && trackedIds.length <= 10) { + const trackedNames = trackedIds + .map(id => monitors.get(id)?.name || `ID ${id}`) + .join(', '); + embed.addFields({ + name: '━━━━━━━━━━━━━━\n🎯 Tracked Monitors', + value: trackedNames, + inline: false, + }); + } + + if (groups.length > 0) { + const groupSummary = groups + .map(g => `**${g.name}**: ${g.monitorIds.length} monitors`) + .join('\n'); + embed.addFields({ + name: '━━━━━━━━━━━━━━\n📁 Groups', + value: groupSummary, + inline: false, + }); + } + + embed.setTimestamp(); + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + private async addGroup( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const name = interaction.options.getString('name', true); + + const success = configStorage.addGroup(name); + + if (success) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Group Created') + .setDescription(`Group **${name}** has been created.\n\nUse \`/group-add-monitor\` to add monitors to this group.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ + content: `❌ A group named **${name}** already exists.`, + flags: MessageFlags.Ephemeral, + }); + } + } + + private async removeGroup( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const groupName = interaction.options.getString('group', true); + + const success = configStorage.removeGroup(groupName); + + if (success) { + const embed = new EmbedBuilder() + .setColor(0xff9900) + .setTitle('🗑️ Group Deleted') + .setDescription(`Group **${groupName}** has been deleted.\n\nMonitors are now ungrouped.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ + content: `❌ Group **${groupName}** not found.`, + flags: MessageFlags.Ephemeral, + }); + } + } + + private async assignMonitorToGroup( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const groupName = interaction.options.getString('group', true); + const monitorIdStr = interaction.options.getString('monitor', true); + const monitorId = parseInt(monitorIdStr, 10); + + const success = configStorage.addMonitorToGroup(groupName, monitorId); + + if (success) { + const monitors = uptimeKuma.getAllMonitors(); + const monitorName = monitors.get(monitorId)?.name || `ID ${monitorId}`; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Monitor Assigned') + .setDescription(`**${monitorName}** → **${groupName}**`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ + content: `❌ Failed to assign. Group **${groupName}** may not exist.`, + flags: MessageFlags.Ephemeral, + }); + } + } + + private async unassignMonitor( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const monitorIdStr = interaction.options.getString('monitor', true); + const monitorId = parseInt(monitorIdStr, 10); + + const success = configStorage.removeMonitorFromGroup(monitorId); + + if (success) { + const monitors = uptimeKuma.getAllMonitors(); + const monitorName = monitors.get(monitorId)?.name || `ID ${monitorId}`; + + const embed = new EmbedBuilder() + .setColor(0xff9900) + .setTitle('🗑️ Monitor Unassigned') + .setDescription(`**${monitorName}** has been removed from its group.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ + content: `❌ This monitor was not assigned to any group.`, + flags: MessageFlags.Ephemeral, + }); + } + } + + private async listGroups( + interaction: ChatInputCommandInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + if (!await this.checkAdmin(interaction)) return; + + const groups = configStorage.getGroups(); + + if (groups.length === 0) { + await interaction.reply({ + content: '📋 No groups created yet. Use `/group-add` to create one.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const monitors = uptimeKuma.getAllMonitors(); + const embed = new EmbedBuilder() + .setColor(configStorage.getEmbedColor()) + .setTitle('📋 Monitor Groups') + .setTimestamp(); + + for (const group of groups) { + const monitorNames = group.monitorIds + .map(id => { + const monitor = monitors.get(id); + return monitor ? `• ${monitor.name}` : `• ID ${id} (not found)`; + }); + + const value = monitorNames.length > 0 + ? monitorNames.join('\n') + : '*No monitors assigned*'; + + embed.addFields({ + name: `${group.name} (${group.monitorIds.length} monitors)`, + value: value, + inline: false, + }); + } + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + + private parseIds(idsString: string): number[] { + return idsString + .split(',') + .map(id => parseInt(id.trim(), 10)) + .filter(id => !isNaN(id) && id > 0); + } + + private chunkArray<T>(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } + + public getCommands(): Command[] { + return Array.from(this.commands.values()); + } + + public getCommand(name: string): Command | undefined { + return this.commands.get(name); + } + + public async handleAutocomplete( + interaction: AutocompleteInteraction, + uptimeKuma: UptimeKumaService + ): Promise<void> { + const focusedOption = interaction.options.getFocused(true); + + try { + if (focusedOption.name === 'monitor') { + await this.autocompleteMonitors(interaction, uptimeKuma, focusedOption.value as string); + } else if (focusedOption.name === 'group') { + await this.autocompleteGroups(interaction, focusedOption.value as string); + } + } catch (error: any) { + this.logger.error(`Autocomplete error: ${error.message}`); + await interaction.respond([]); + } + } + + private async autocompleteMonitors( + interaction: AutocompleteInteraction, + uptimeKuma: UptimeKumaService, + query: string + ): Promise<void> { + const monitors = uptimeKuma.getAllMonitors(); + const lowerQuery = query.toLowerCase(); + + const filtered = Array.from(monitors.entries()) + .filter(([id, monitor]) => + monitor.name.toLowerCase().includes(lowerQuery) || + id.toString().includes(query) || + query === '' + ) + .slice(0, 25) + .map(([id, monitor]) => ({ + name: `${monitor.name} (ID: ${id})`, + value: id.toString(), + })); + + await interaction.respond(filtered); + } + + private async autocompleteGroups( + interaction: AutocompleteInteraction, + query: string + ): Promise<void> { + const groups = configStorage.getGroups(); + const lowerQuery = query.toLowerCase(); + + const filtered = groups + .filter(group => group.name.toLowerCase().includes(lowerQuery)) + .slice(0, 25) + .map(group => ({ + name: `${group.name} (${group.monitorIds.length} monitors)`, + value: group.name, + })); + + if (filtered.length === 0 && groups.length > 0) { + const allGroups = groups + .slice(0, 25) + .map(group => ({ + name: `${group.name} (${group.monitorIds.length} monitors)`, + value: group.name, + })); + await interaction.respond(allGroups); + } else { + await interaction.respond(filtered); + } + } +} + diff --git a/src/services/discord.service.ts b/src/services/discord.service.ts new file mode 100644 index 0000000..88f6966 --- /dev/null +++ b/src/services/discord.service.ts @@ -0,0 +1,374 @@ +import { Client, GatewayIntentBits, TextChannel, EmbedBuilder, Message, REST, Routes, MessageFlags } from 'discord.js'; +import { config } from '../config/config'; +import { configStorage } from '../config/storage'; +import { MonitorStats, HeartbeatStatus } from '../types/uptime-kuma'; +import { UptimeKumaService } from './uptime-kuma.service'; +import { CommandsService } from './commands.service'; +import { Logger } from '../utils/logger'; + +export class DiscordService { + private client: Client; + private channel: TextChannel | null = null; + private logger: Logger; + private maxMonitorsPerEmbed = 20; + private commandsService: CommandsService; + private uptimeKumaService: UptimeKumaService | null = null; + + constructor() { + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + ], + }); + this.logger = new Logger('DiscordService'); + this.commandsService = new CommandsService(); + (this.client as any).discordService = this; + } + + public setUptimeKumaService(service: UptimeKumaService): void { + this.uptimeKumaService = service; + } + + public getClient(): Client { + return this.client; + } + + public async connect(): Promise<void> { + return new Promise((resolve, reject) => { + this.client.once('ready', async (client) => { + this.logger.info(`Discord bot logged in as ${client.user?.tag}`); + + try { + await this.initializeChannel(); + await this.registerCommands(); + this.setupCommandHandler(); + resolve(); + } catch (error: any) { + this.logger.warn(`Channel initialization skipped: ${error.message}`); + await this.registerCommands(); + this.setupCommandHandler(); + resolve(); + } + }); + + this.client.on('error', (error: Error) => { + this.logger.error(`Discord client error: ${error.message}`); + }); + + this.client.login(config.discord.token).catch(reject); + }); + } + + private async registerCommands(): Promise<void> { + try { + const commands = this.commandsService.getCommands().map(cmd => cmd.data.toJSON()); + + const rest = new REST({ version: '10' }).setToken(config.discord.token); + + this.logger.info('Registering slash commands...'); + + await rest.put( + Routes.applicationCommands(this.client.user!.id), + { body: commands } + ); + + this.logger.info(`Successfully registered ${commands.length} slash commands`); + } catch (error: any) { + this.logger.error(`Failed to register commands: ${error.message}`); + throw error; + } + } + + private setupCommandHandler(): void { + this.client.on('interactionCreate', async (interaction: any) => { + if (interaction.isAutocomplete()) { + await this.commandsService.handleAutocomplete(interaction, this.uptimeKumaService!); + return; + } + + if (!interaction.isChatInputCommand()) return; + + const command = this.commandsService.getCommand(interaction.commandName); + + if (!command) { + this.logger.warn(`Unknown command: ${interaction.commandName}`); + return; + } + + try { + if (!this.uptimeKumaService) { + await interaction.reply({ + content: '❌ Uptime Kuma service not initialized', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await command.execute(interaction, this.uptimeKumaService); + } catch (error: any) { + this.logger.error(`Error executing command ${interaction.commandName}: ${error.message}`); + + const errorMessage = '❌ An error occurred while executing this command.'; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: errorMessage, flags: MessageFlags.Ephemeral }); + } + } + }); + } + + private async initializeChannel(): Promise<void> { + const channelId = configStorage.getChannelId(); + + if (!channelId) { + throw new Error('No channel configured. Use /set-channel command to set one.'); + } + + try { + const channel = await this.client.channels.fetch(channelId); + + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + throw new Error('Invalid channel or channel is not a text channel'); + } + + this.channel = channel as TextChannel; + this.logger.info(`Initialized channel: ${this.channel.name}`); + } catch (error: any) { + throw new Error(`Failed to initialize Discord channel: ${error.message}`); + } + } + + public async setChannel(channelId: string): Promise<void> { + try { + const channel = await this.client.channels.fetch(channelId); + + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + throw new Error('Invalid channel or channel is not a text channel'); + } + + this.channel = channel as TextChannel; + configStorage.setMessageIds([]); + configStorage.setChannelId(channelId); + this.logger.info(`Changed status channel to: ${this.channel.name}`); + } catch (error: any) { + throw new Error(`Failed to set channel: ${error.message}`); + } + } + + public async updateMonitorStatus(monitors: MonitorStats[]): Promise<void> { + if (!this.channel) { + this.logger.warn('Channel not initialized, skipping update'); + return; + } + + try { + const trackedIds = configStorage.getMonitorIds(); + const filteredMonitors = trackedIds.length === 0 + ? monitors + : monitors.filter(m => trackedIds.includes(m.monitor.id)); + + if (filteredMonitors.length === 0) { + this.logger.warn('No monitors to display after filtering'); + return; + } + + const embeds = this.createEmbeds(filteredMonitors, monitors.length); + + const messageIds = configStorage.getMessageIds(); + if (messageIds.length === 0) { + await this.createNewMessages(embeds); + } else { + await this.updateExistingMessages(embeds); + } + } catch (error: any) { + this.logger.error(`Failed to update monitor status: ${error.message}`); + } + } + + private createEmbeds(monitors: MonitorStats[], totalMonitors: number): EmbedBuilder[] { + const embeds: EmbedBuilder[] = []; + const statusMessage = configStorage.getStatusMessage(); + const groups = configStorage.getGroups(); + + const embed = new EmbedBuilder() + .setColor(configStorage.getEmbedColor()) + .setTitle(statusMessage) + .setTimestamp() + .setFooter({ text: 'Last updated' }); + + const summary = this.generateSummary(monitors, totalMonitors); + embed.setDescription(summary); + + if (groups.length > 0) { + const monitorMap = new Map(monitors.map(m => [m.monitor.id, m])); + const assignedIds = new Set<number>(); + + for (const group of groups) { + const groupMonitors = group.monitorIds + .map(id => monitorMap.get(id)) + .filter((m): m is MonitorStats => m !== undefined); + + if (groupMonitors.length > 0) { + groupMonitors.forEach(m => assignedIds.add(m.monitor.id)); + + const monitorsList = groupMonitors.map(stats => + `${this.getStatusEmoji(stats.currentStatus)} **${stats.monitor.name}** - ${this.formatMonitorStatus(stats)}` + ).join('\n'); + + embed.addFields({ + name: `━━━━━━━━━━━━━━━━━━━━\n${group.name}`, + value: monitorsList, + }); + } + } + + const ungrouped = monitors.filter(m => !assignedIds.has(m.monitor.id)); + if (ungrouped.length > 0) { + const monitorsList = ungrouped.map(stats => + `${this.getStatusEmoji(stats.currentStatus)} **${stats.monitor.name}** - ${this.formatMonitorStatus(stats)}` + ).join('\n'); + + embed.addFields({ + name: '━━━━━━━━━━━━━━━━━━━━\nOther Services', + value: monitorsList, + }); + } + } else { + const monitorsList = monitors.map(stats => + `${this.getStatusEmoji(stats.currentStatus)} **${stats.monitor.name}** - ${this.formatMonitorStatus(stats)}` + ).join('\n'); + + embed.addFields({ + name: '━━━━━━━━━━━━━━━━━━━━\nMonitored Services', + value: monitorsList || 'No monitors', + }); + } + + embeds.push(embed); + return embeds; + } + + private getStatusEmoji(status: HeartbeatStatus): string { + switch (status) { + case HeartbeatStatus.UP: + return '🟢'; + case HeartbeatStatus.DOWN: + return '🔴'; + case HeartbeatStatus.PENDING: + return '🟡'; + case HeartbeatStatus.MAINTENANCE: + return '🔵'; + default: + return '⚪'; + } + } + + private formatMonitorStatus(stats: MonitorStats): string { + const parts: string[] = []; + + parts.push(HeartbeatStatus[stats.currentStatus]); + + if (stats.uptime24h !== undefined) { + parts.push(`${stats.uptime24h.toFixed(1)}% uptime`); + } + + if (stats.avgPing !== undefined) { + parts.push(`${stats.avgPing.toFixed(0)}ms`); + } + + return parts.join(' • '); + } + + private generateSummary(monitors: MonitorStats[], totalMonitors: number): string { + if (monitors.length === 0) return ''; + + const statusCounts = { + up: monitors.filter(m => m.currentStatus === HeartbeatStatus.UP).length, + down: monitors.filter(m => m.currentStatus === HeartbeatStatus.DOWN).length, + pending: monitors.filter(m => m.currentStatus === HeartbeatStatus.PENDING).length, + maintenance: monitors.filter(m => m.currentStatus === HeartbeatStatus.MAINTENANCE).length, + }; + + const total = monitors.length; + const uptimePercentage = total > 0 ? ((statusCounts.up / total) * 100).toFixed(1) : '0'; + + const trackingInfo = monitors.length < totalMonitors + ? `**Tracking ${total} of ${totalMonitors} monitors**\n\n` + : ''; + + return trackingInfo + + `**Overall Status:** ${uptimePercentage}% Operational\n\n` + + `🟢 **Online:** ${statusCounts.up}\n` + + `🔴 **Offline:** ${statusCounts.down}\n` + + `🟡 **Pending:** ${statusCounts.pending}\n` + + `🔵 **Maintenance:** ${statusCounts.maintenance}`; + } + + private async createNewMessages(embeds: EmbedBuilder[]): Promise<void> { + if (!this.channel) return; + + const newMessageIds: string[] = []; + for (const embed of embeds) { + const message = await this.channel.send({ embeds: [embed] }); + newMessageIds.push(message.id); + } + + configStorage.setMessageIds(newMessageIds); + this.logger.info(`Created ${embeds.length} new status message(s)`); + } + + private async updateExistingMessages(embeds: EmbedBuilder[]): Promise<void> { + if (!this.channel) return; + + const messageIds = configStorage.getMessageIds(); + const newMessageIds: string[] = []; + + for (let i = 0; i < embeds.length; i++) { + try { + if (i < messageIds.length) { + const message = await this.channel.messages.fetch(messageIds[i]); + await message.edit({ embeds: [embeds[i]] }); + newMessageIds.push(messageIds[i]); + } else { + const message = await this.channel.send({ embeds: [embeds[i]] }); + newMessageIds.push(message.id); + } + } catch (error: any) { + this.logger.error(`Failed to update message: ${error.message}`); + configStorage.setMessageIds([]); + await this.createNewMessages(embeds); + return; + } + } + + if (embeds.length < messageIds.length) { + const toDelete = messageIds.slice(embeds.length); + for (const messageId of toDelete) { + try { + const message = await this.channel.messages.fetch(messageId); + await message.delete(); + } catch (error: any) { + this.logger.warn(`Failed to delete message ${messageId}: ${error.message}`); + } + } + } + + configStorage.setMessageIds(newMessageIds); + } + + private chunkArray<T>(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } + + public disconnect(): void { + this.logger.info('Disconnecting Discord bot'); + this.client.destroy(); + } +} + diff --git a/src/services/uptime-kuma.service.ts b/src/services/uptime-kuma.service.ts new file mode 100644 index 0000000..8d89ff2 --- /dev/null +++ b/src/services/uptime-kuma.service.ts @@ -0,0 +1,254 @@ +import { io, Socket } from 'socket.io-client'; +import { EventEmitter } from 'events'; +import { config } from '../config/config'; +import { Monitor, Heartbeat, MonitorStats, HeartbeatStatus, UptimeKumaResponse } from '../types/uptime-kuma'; +import { Logger } from '../utils/logger'; + +export class UptimeKumaService extends EventEmitter { + private socket: Socket | null = null; + private monitors: Map<number, MonitorStats> = new Map(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private reconnectDelay = 5000; + private isAuthenticated = false; + private logger: Logger; + + constructor() { + super(); + this.logger = new Logger('UptimeKumaService'); + } + + public async connect(): Promise<void> { + return new Promise((resolve, reject) => { + try { + this.logger.info(`Connecting to Uptime Kuma at ${config.uptimeKuma.url}`); + + this.socket = io(config.uptimeKuma.url, { + reconnection: true, + reconnectionDelay: this.reconnectDelay, + reconnectionAttempts: this.maxReconnectAttempts, + transports: ['websocket', 'polling'], + }); + + this.setupSocketListeners(); + + this.socket.once('connect', () => { + this.logger.info('Connected to Uptime Kuma, attempting authentication...'); + this.authenticate() + .then(() => { + this.reconnectAttempts = 0; + resolve(); + }) + .catch(reject); + }); + + this.socket.once('connect_error', (error) => { + this.logger.error(`Connection error: ${error.message}`); + reject(error); + }); + } catch (error) { + reject(error); + } + }); + } + + private setupSocketListeners(): void { + if (!this.socket) return; + + this.socket.on('disconnect', (reason) => { + this.logger.warn(`Disconnected from Uptime Kuma: ${reason}`); + this.isAuthenticated = false; + this.emit('disconnected', reason); + }); + + this.socket.on('connect', () => { + if (this.isAuthenticated) { + this.logger.info('Reconnected to Uptime Kuma'); + this.authenticate().catch(err => { + this.logger.error(`Re-authentication failed: ${err.message}`); + }); + } + }); + + this.socket.on('monitorList', (data: Record<string, Monitor>) => { + this.handleMonitorList(data); + }); + + this.socket.on('heartbeat', (heartbeat: Heartbeat) => { + this.handleHeartbeat(heartbeat); + }); + + this.socket.on('avgPing', (data: { monitorID: number; avgPing: number | null }) => { + this.handleAvgPing(data.monitorID, data.avgPing); + }); + + this.socket.on('uptime', (data: { monitorID: number; periodKey: string; percentage: number }) => { + if (data.periodKey === '24') { + this.handleUptime(data.monitorID, data.percentage); + } + }); + + this.socket.on('connect_error', (error) => { + this.logger.error(`Socket.io connection error: ${error.message}`); + }); + + this.socket.on('error', (error) => { + this.logger.error(`Socket.io error: ${error}`); + }); + } + + private async authenticate(): Promise<void> { + return new Promise((resolve, reject) => { + if (!this.socket || !this.socket.connected) { + return reject(new Error('Socket not connected')); + } + + const timeout = setTimeout(() => { + reject(new Error('Authentication timeout')); + }, 10000); + + this.logger.info('Authenticating with Uptime Kuma...'); + this.socket.emit( + 'login', + { + username: config.uptimeKuma.username, + password: config.uptimeKuma.password, + token: '', + }, + (response: UptimeKumaResponse) => { + clearTimeout(timeout); + + if (response.ok) { + this.isAuthenticated = true; + this.logger.info('Successfully authenticated with Uptime Kuma'); + resolve(); + } else { + const errorMsg = response.msg || 'Authentication failed'; + this.logger.error(`Authentication failed: ${errorMsg}`); + reject(new Error(errorMsg)); + } + } + ); + }); + } + + public getAllMonitors(): Map<number, Monitor> { + const allMonitors = new Map<number, Monitor>(); + for (const [id, stats] of this.monitors.entries()) { + allMonitors.set(id, stats.monitor); + } + return allMonitors; + } + + private handleMonitorList(data: Record<string, Monitor>, filterByIds?: number[]): void { + this.logger.info(`Received monitor list with ${Object.keys(data).length} monitors`); + + for (const [id, monitor] of Object.entries(data)) { + const monitorId = parseInt(id, 10); + const existing = this.monitors.get(monitorId); + + this.monitors.set(monitorId, { + monitor, + currentStatus: existing?.currentStatus || HeartbeatStatus.PENDING, + lastHeartbeat: existing?.lastHeartbeat, + avgPing: existing?.avgPing, + uptime24h: existing?.uptime24h, + }); + } + + this.emit('monitorsUpdated', this.getMonitorStats()); + } + + private handleHeartbeat(heartbeat: Heartbeat): void { + if (!this.shouldTrackMonitor(heartbeat.monitorID)) { + return; + } + + const stats = this.monitors.get(heartbeat.monitorID); + if (stats) { + const oldStatus = stats.currentStatus; + stats.currentStatus = heartbeat.status; + stats.lastHeartbeat = heartbeat; + + if (oldStatus !== heartbeat.status && heartbeat.important) { + this.logger.info(`Monitor ${stats.monitor.name} status changed: ${HeartbeatStatus[oldStatus]} -> ${HeartbeatStatus[heartbeat.status]}`); + this.emit('statusChanged', stats); + } + + this.emit('monitorsUpdated', this.getMonitorStats()); + } + } + + private handleAvgPing(monitorID: number, avgPing: number | null): void { + if (!this.shouldTrackMonitor(monitorID)) { + return; + } + + const stats = this.monitors.get(monitorID); + if (stats) { + stats.avgPing = avgPing || undefined; + } + } + + private handleUptime(monitorID: number, percentage: number): void { + if (!this.shouldTrackMonitor(monitorID)) { + return; + } + + const stats = this.monitors.get(monitorID); + if (stats) { + stats.uptime24h = percentage; + } + } + + public setMonitorIds(ids: number[]): void { + const oldIds = Array.from(this.monitors.keys()); + const newIds = ids.length === 0 ? oldIds : ids; + + for (const id of oldIds) { + if (!newIds.includes(id)) { + this.monitors.delete(id); + } + } + + this.emit('monitorsUpdated', this.getMonitorStats()); + } + + private shouldTrackMonitor(monitorId: number, monitorIds?: number[]): boolean { + const ids = monitorIds || this.getCurrentMonitorIds(); + if (ids.length === 0) { + return true; + } + return ids.includes(monitorId); + } + + private getCurrentMonitorIds(): number[] { + return []; + } + + public getMonitorStats(filterIds?: number[]): MonitorStats[] { + const stats = Array.from(this.monitors.values()); + + if (!filterIds || filterIds.length === 0) { + return stats.sort((a, b) => a.monitor.name.localeCompare(b.monitor.name)); + } + + return stats + .filter(s => filterIds.includes(s.monitor.id)) + .sort((a, b) => a.monitor.name.localeCompare(b.monitor.name)); + } + + public isConnected(): boolean { + return this.socket !== null && this.socket.connected && this.isAuthenticated; + } + + public disconnect(): void { + if (this.socket) { + this.logger.info('Disconnecting from Uptime Kuma'); + this.socket.disconnect(); + this.socket = null; + this.isAuthenticated = false; + } + } +} + diff --git a/src/types/uptime-kuma.ts b/src/types/uptime-kuma.ts new file mode 100644 index 0000000..6b09be4 --- /dev/null +++ b/src/types/uptime-kuma.ts @@ -0,0 +1,53 @@ +export interface Monitor { + id: number; + name: string; + type: string; + url?: string; + active: boolean; + interval: number; + tags?: MonitorTag[]; +} + +export interface MonitorTag { + tag_id: number; + monitor_id: number; + value: string | null; + name: string; + color: string; +} + +export interface Heartbeat { + monitorID: number; + status: HeartbeatStatus; + time: string; + msg: string; + ping: number | null; + important: boolean; + duration: number; + localDateTime: string; + timezone: string; + retries: number; + downCount: number; +} + +export enum HeartbeatStatus { + DOWN = 0, + UP = 1, + PENDING = 2, + MAINTENANCE = 3, +} + +export interface MonitorStats { + monitor: Monitor; + currentStatus: HeartbeatStatus; + lastHeartbeat?: Heartbeat; + avgPing?: number; + uptime24h?: number; +} + +export interface UptimeKumaResponse { + ok: boolean; + msg?: string; + token?: string; +} + diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..28a27c8 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,29 @@ +export class Logger { + private context: string; + + constructor(context: string) { + this.context = context; + } + + private formatMessage(level: string, message: string): string { + const timestamp = new Date().toISOString(); + return `[${timestamp}] [${level}] [${this.context}] ${message}`; + } + + public info(message: string): void { + console.log(this.formatMessage('INFO', message)); + } + + public warn(message: string): void { + console.warn(this.formatMessage('WARN', message)); + } + + public error(message: string): void { + console.error(this.formatMessage('ERROR', message)); + } + + public debug(message: string): void { + console.debug(this.formatMessage('DEBUG', message)); + } +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bbce4ff --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +