mirror of
https://github.com/BrenBroZAYT/uptime-kuma-discord-bot.git
synced 2026-06-13 16:40:03 +00:00
initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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=
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
bot-config.json
|
||||||
|
.fly/
|
||||||
|
|
||||||
+46
@@ -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"]
|
||||||
|
|
||||||
+184
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
+124
@@ -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.
|
||||||
@@ -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 <channel>` | Set where to post status updates |
|
||||||
|
| `/set-title <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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
Generated
+640
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
+141
@@ -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();
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user