Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1cf20347 | ||
|
|
6a0c2d1fc4 | ||
|
|
5bc85effa7 | ||
|
|
13a670eadd | ||
|
|
0cedb51e50 | ||
|
|
6c819f8c52 | ||
|
|
2a1f5e11f8 |
@@ -2,7 +2,9 @@
|
|||||||
agent.md
|
agent.md
|
||||||
config.json
|
config.json
|
||||||
bot_user_config.json
|
bot_user_config.json
|
||||||
|
bot_node_config.json
|
||||||
*.exe
|
*.exe
|
||||||
|
nukumizu-linux-amd64
|
||||||
|
|
||||||
# SQLite database files. They live on a network share (Y:), and git/cloud
|
# SQLite database files. They live on a network share (Y:), and git/cloud
|
||||||
# sync touching them while a WAL database is open corrupts the WAL index and
|
# sync touching them while a WAL database is open corrupts the WAL index and
|
||||||
|
|||||||
@@ -2,94 +2,144 @@
|
|||||||
|
|
||||||
Remote server monitoring and command execution subsystem for [Komari](https://www.komari.wiki).
|
Remote server monitoring and command execution subsystem for [Komari](https://www.komari.wiki).
|
||||||
|
|
||||||
## Overview
|
Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of every monitored server, and pushes alerts to several notification channels. It also runs interactive bots on QQ (via [NapCat](https://napneko.github.io/), OneBot 11) and Telegram so operators can control servers from chat.
|
||||||
|
|
||||||
Nukumizu connects to a Komari Dashboard instance to:
|
## Features
|
||||||
- Monitor server status in real-time via WebSocket
|
|
||||||
- Execute commands on remote servers via the Komari task API
|
|
||||||
- Provide Bot interfaces on QQ (via NapCat) and Telegram for interactive server control
|
|
||||||
- Send one-way status notifications via Email, Ntfy, and Webhook
|
|
||||||
|
|
||||||
## Project Structure
|
- **Komari integration** — logs into the Komari Dashboard, refreshes the node list on startup and every 5 minutes, and polls each node's latest status through the Komari WebSocket every 5 seconds.
|
||||||
|
- **Status tracking** — a thread-safe node tracker keeps the latest report and static info per server and detects `Online` / `Offline` transitions.
|
||||||
|
- **Remote command execution** — dispatches commands through the Komari task API and polls the result (1s interval, up to 60s timeout).
|
||||||
|
- **Interactive bots** — QQ (NapCat / OneBot 11) and Telegram bots for `/list`, `/status`, `/info`, `/run`, `/shutdown`, `/reboot`, and more, protected by an admin / trusted-group permission model.
|
||||||
|
- **Notification channels** — server status changes are pushed to every enabled channel: QQ, Telegram, Email (SMTP), [ntfy](https://ntfy.sh), and Webhook.
|
||||||
|
- **Network proxy** — a global proxy URL can be enabled per controller (`networkUseProxy`) for HTTP, WebSocket, and even SMTP (HTTP CONNECT tunnel).
|
||||||
|
- **Customizable message templates** — every bot/notification message is rendered from a template in `config.json`.
|
||||||
|
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
|
||||||
|
- **Dashboard API** — token-authenticated REST API plus a live log-streaming WebSocket.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. On startup Nukumizu logs in to the Komari Dashboard. A failed login aborts the process.
|
||||||
|
2. It fetches the node list (name → UUID, static info) and stores it in memory, then opens a WebSocket to poll live status every 5 seconds. If the connection drops it reconnects with exponential backoff; after 5 failed attempts it notifies and keeps retrying.
|
||||||
|
3. The node tracker uses the first received snapshot as a baseline so a restart does not produce false "offline" alerts, then fires a status-change event for every real transition.
|
||||||
|
4. Each status-change event is rendered through the `SERVER_STATUS_CHANGED` template and sent to all enabled controllers. QQ / Telegram additionally receive a startup welcome and the initial server list.
|
||||||
|
5. Every 12 hours the Komari session is re-authenticated; the node list is refreshed every 5 minutes.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
nukumizu-backend/
|
nukumizu-backend/
|
||||||
├── main.go # Entry point, startup sequence, graceful shutdown
|
├── main.go # Entry point, startup sequence, graceful shutdown
|
||||||
├── router.go # HTTP route registration
|
├── router.go # HTTP route registration
|
||||||
├── config/
|
├── config/
|
||||||
│ └── config.go # Configuration loading, defaults
|
│ ├── config.go # Load config files, apply defaults
|
||||||
|
│ └── variables.go # Config schema structs + globals
|
||||||
|
├── global/
|
||||||
|
│ └── variables.go # Software build metadata (name/version/developer)
|
||||||
├── handler/
|
├── handler/
|
||||||
│ ├── user.go # User login/register handlers
|
│ ├── user.go # /api/user/login, /api/user/register
|
||||||
│ ├── server.go # Server list/status/exec handlers
|
│ ├── server.go # /api/server/list, getStatus, exec
|
||||||
│ └── health.go # Health check endpoint
|
│ └── health.go # /health
|
||||||
├── database/
|
├── database/
|
||||||
│ └── user.go # SQLite user database
|
│ └── user.go # user.db (SQLite) user store
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── auth.go # Token management, authentication
|
│ ├── auth.go # Token management, Auth middleware, JSON responses
|
||||||
│ └── middleware.go # Rate limit, CORS, XSS protection
|
│ └── middleware.go # Rate limit, CORS, XSS/security headers
|
||||||
├── postLog/ # Logging subsystem
|
├── postLog/ # Logging subsystem
|
||||||
├── internal/
|
│ ├── postLog.go # Leveled logger (stdout + broadcast)
|
||||||
│ ├── komari/
|
│ ├── database.go # log.db (SQLite, one table per run)
|
||||||
│ │ ├── client.go # Komari HTTP API client
|
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
||||||
│ │ └── ws.go # Komari WebSocket client
|
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
|
||||||
│ ├── qq/
|
└── internal/
|
||||||
│ │ ├── qq.go # QQ (Napcat) Bot controller
|
├── komari/
|
||||||
│ │ └── napcat.go # NapCat HTTP API + WebSocket client
|
│ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
||||||
│ ├── node/
|
│ └── ws.go # Komari status WebSocket (poll + reconnect)
|
||||||
│ │ └── tracker.go # Thread-safe node state tracking
|
├── node/
|
||||||
│ ├── controller/
|
│ └── tracker.go # Thread-safe server state, status-change detection
|
||||||
│ │ ├── controller.go # Controller interface & manager
|
├── netproxy/
|
||||||
│ │ └── pipes/
|
│ └── netproxy.go # Unified network proxy for controllers
|
||||||
│ │ ├── telegram.go # Telegram Bot controller
|
├── template/
|
||||||
│ │ ├── email.go # Email notification controller
|
│ └── template.go # Message template renderer ({{ variables }})
|
||||||
│ │ ├── ntfy.go # Ntfy notification controller
|
└── controller/
|
||||||
│ │ └── webhook.go # Webhook notification controller
|
├── controller.go # Manager, Controller / BotController interfaces
|
||||||
│ └── template/
|
├── trigger.go # Command parsing, authorization, routing
|
||||||
│ └── template.go # Message template engine
|
├── processor.go # Command handlers
|
||||||
|
├── utils.go
|
||||||
|
└── pipes/
|
||||||
|
├── email.go # Email notification pipe
|
||||||
|
├── ntfy.go # ntfy notification pipe
|
||||||
|
├── webhook.go # Webhook notification pipe
|
||||||
|
├── qq_napcat/
|
||||||
|
│ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||||
|
│ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||||
|
└── telegram/
|
||||||
|
├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||||
|
└── send.go # Message sending / splitting (Telegram Markdown)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Go **1.25** or newer
|
||||||
|
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||||
|
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint
|
||||||
|
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
||||||
|
|
||||||
|
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy and modify `config.json` at the project root:
|
There are two configuration files, both read from the working directory unless overridden:
|
||||||
|
|
||||||
|
| File | CLI flag | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `config.json` | `-config` | `config.json` | Core settings: system, Komari, controllers, message templates |
|
||||||
|
| `bot_user_config.json` | `-bot-user-config` | `bot_user_config.json` | Per-bot admins / trusted groups and their notification preferences |
|
||||||
|
|
||||||
|
> Both files are in `.gitignore` because they contain credentials (Komari password, bot tokens, proxy auth). Start from the samples below and never commit real secrets.
|
||||||
|
|
||||||
|
### `config.json`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"system": {
|
"system": {
|
||||||
"debugMode": true,
|
"debugMode": true,
|
||||||
"listenAddr": "0.0.0.0",
|
"listenAddr": "0.0.0.0",
|
||||||
"listenPort": "8080"
|
"listenPort": "8080",
|
||||||
|
"networkProxy": "http://127.0.0.1:7890"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"showNapcatMsg": false,
|
"showNapcatMsg": false,
|
||||||
"showTelegramMsg": false
|
"showNapcatAction": false,
|
||||||
|
"showTelegramMsg": false,
|
||||||
|
"showTriggerCmdEcho": true,
|
||||||
|
"showKomariTaskEcho": false,
|
||||||
|
"napcatIgnoreSelfMsg": false
|
||||||
},
|
},
|
||||||
"komari": {
|
"komari": {
|
||||||
"dashboardURL": "http://127.0.0.1:25774",
|
"dashboardURL": "https://status.example.com",
|
||||||
"account": {
|
"account": {
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "admin"
|
"password": "CHANGE_ME"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"controllerMethod": {
|
"controllerMethod": {
|
||||||
"qq(napcat)": {
|
"qq(napcat)": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"networkUseProxy": false,
|
||||||
"napcatAddr": "127.0.0.1",
|
"napcatAddr": "127.0.0.1",
|
||||||
"napcatPort": "3000",
|
"napcatPort": "3000",
|
||||||
"napcatToken": "",
|
"napcatToken": "",
|
||||||
"botQQID": 0,
|
"botQQID": 0,
|
||||||
"listenMethod": "global",
|
"listenMethod": "global"
|
||||||
"admins": [],
|
|
||||||
"trustedGroups": []
|
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"networkUseProxy": false,
|
||||||
"botToken": "",
|
"botToken": "",
|
||||||
"listenMethod": "global",
|
"listenMethod": "global"
|
||||||
"admins": [],
|
|
||||||
"trustedGroups": []
|
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"networkUseProxy": false,
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpPort": 587,
|
"smtpPort": 587,
|
||||||
"username": "",
|
"username": "",
|
||||||
@@ -100,6 +150,7 @@ Copy and modify `config.json` at the project root:
|
|||||||
},
|
},
|
||||||
"ntfy": {
|
"ntfy": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"networkUseProxy": false,
|
||||||
"server": "https://ntfy.sh",
|
"server": "https://ntfy.sh",
|
||||||
"topic": "",
|
"topic": "",
|
||||||
"token": "",
|
"token": "",
|
||||||
@@ -107,6 +158,7 @@ Copy and modify `config.json` at the project root:
|
|||||||
},
|
},
|
||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"networkUseProxy": false,
|
||||||
"url": "",
|
"url": "",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"headers": {},
|
"headers": {},
|
||||||
@@ -114,51 +166,202 @@ Copy and modify `config.json` at the project root:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"controllerMessage": {
|
"controllerMessage": {
|
||||||
"SERVER_STATUS_CHANGED": "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}",
|
"BOT_STARTED": "Nukumizu Alert Bot Started\nTime: {{ time }}\n- Software Version: {{ softwareVersion }}\n- Build Version: {{ softwareBuildVer }}\n- Commit Hash: {{ softwareCommitHash }}\n- Build Type: {{ softwareBuildType }}\n- Build Time: {{ softwareBuildTime }}\n- Developer: {{ softwareDeveloper }}",
|
||||||
"SERVER_LIST": "All server list:\nOnline:\n{{ list.onlineServers }}\nOffline:\n{{ list.offlineServers }}",
|
"BOT_HELP": "Nukumizu Alert Bot Ver. {{ softwareVersion }}.{{ softwareBuildVer }}.{{ softwareCommitHash }}\nCommand Lists:\n- /help: Show this help message\n- /list: List all servers and show their status\n- /status <UUID>: Show specific server status\n- /info <UUID>: Show specific server info\n- /run <UUID> <command>: Execute command on specific server. If you type \"all\" in <UUID>, you will run the command on all servers.\n- /shutdown <UUID>: Shutdown specific server.\n- /reboot <UUID>: Reboot specific server.\n- /getip <UUID>: Get specific server IP address.",
|
||||||
"SERVER_EXECUTE_RESULT": "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
|
"TG_BOT_START": "Welcome to use Nukumizu Alert Bot!\nUse `/help` to get command list.",
|
||||||
|
"SERVER_STATUS_CHANGED": "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\n- Event: {{ event }}\n- Server Name: {{ serverName }}\n- Message: {{ message }}\n- Time: {{ time }}",
|
||||||
|
"SERVER_LIST": "All server list:\n- Online:\n{{ list.onlineServers }}\n- Offline:\n{{ list.offlineServers }}",
|
||||||
|
"SERVER_EXECUTE_RESULT": "Command execute result:\n- Server ID: {{ serverName }}\n- Command: {{ command }}\n-----**Result**-----\n\n{{ result }}\n----------\n\n- Time: {{ time }}"
|
||||||
|
},
|
||||||
|
"dataPath": "./data",
|
||||||
|
"dbPath": "./db"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field notes:
|
||||||
|
|
||||||
|
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||||
|
- `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
|
||||||
|
- `debug` toggles verbose per-channel message/action logging; these only matter in debug builds / `debugMode`.
|
||||||
|
- `email.useTLS` is kept for configuration compatibility.
|
||||||
|
- `dataPath` / `dbPath` default to `./data` and `./db`; `user.db` and `log.db` are created under `dbPath`.
|
||||||
|
- Missing keys fall back to built-in defaults (host `0.0.0.0`, port `8080`, NapCat `127.0.0.1:3000`, ntfy server `https://ntfy.sh`, webhook method `POST`, etc.). Message templates have built-in fallbacks too.
|
||||||
|
|
||||||
|
### `bot_user_config.json`
|
||||||
|
|
||||||
|
Admins and trusted groups are defined **per bot channel** and map a member ID to that member's notification preferences:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qq(napcat)": {
|
||||||
|
"admins": {
|
||||||
|
"123456789": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": true,
|
||||||
|
"event_reply": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trustedGroups": {
|
||||||
|
"987654321": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": false,
|
||||||
|
"event_reply": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"admins": {
|
||||||
|
"user_handle": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": true,
|
||||||
|
"event_reply": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trustedGroups": {
|
||||||
|
"-1001234567890": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": false,
|
||||||
|
"event_reply": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
- **QQ**: member IDs are QQ numbers; group IDs are the numeric group number.
|
||||||
|
- **Telegram**: member IDs may be `@username` (resolved to the numeric user ID once that user has messaged the bot) or the numeric user ID; group IDs are the numeric chat ID (supergroups are negative).
|
||||||
|
- Per-member options:
|
||||||
|
- `event_status_notify` — receive server `Online`/`Offline` push notifications.
|
||||||
|
- `event_bot_started` — receive the automatic startup message (welcome + initial server list).
|
||||||
|
- `event_reply` — reserved for opting out of replies to that member's own commands.
|
||||||
|
- Admins of a channel also appear in every trusted-group/private-chat context where the bot sends notifications.
|
||||||
|
|
||||||
All API responses follow the format: `{"success": bool, "message": "..."}`
|
### Message templates
|
||||||
|
|
||||||
Authentication is via `X-Token` and `X-Timestamp` HTTP headers.
|
`controllerMessage` templates are rendered before sending. Available variables (rendered through the Telegram pipe are additionally wrapped in Telegram legacy Markdown):
|
||||||
|
|
||||||
| Endpoint | Method | Auth | Description |
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `{{ time }}` | Current server time |
|
||||||
|
| `{{ serverName }}` | Targeted server name |
|
||||||
|
| `{{ serverUUID }}` | Targeted server UUID |
|
||||||
|
| `{{ upStatus }}` | `Online` / `Offline` |
|
||||||
|
| `{{ event }}` | Status change event (`Online` / `Offline`) |
|
||||||
|
| `{{ message }}` | Message accompanying a status event |
|
||||||
|
| `{{ command }}` | The command that was executed |
|
||||||
|
| `{{ result }}` | Command execution result |
|
||||||
|
| `{{ list.onlineServers }}` | Formatted list of online servers (`- Name (uuid)`) |
|
||||||
|
| `{{ list.offlineServers }}` | Formatted list of offline servers |
|
||||||
|
| `{{ softwareVersion }}`, `{{ softwareBuildVer }}`, `{{ softwareCommitHash }}`, `{{ softwareBuildType }}`, `{{ softwareBuildTime }}`, `{{ softwareDeveloper }}`, `{{ softwareDescription }}` | Build metadata (commit hash and build time are injected at compile time) |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All responses follow the envelope `{"success": true|false, "message": "...", ...data}`. `message` is empty on success unless noted.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Requests are authenticated with HTTP headers:
|
||||||
|
|
||||||
|
| Header | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `X-Token` | Token returned by login/register. Held in memory only (lost on restart). |
|
||||||
|
| `X-Timestamp` | Unix timestamp (seconds); rejected if more than ±30 minutes from server time. **Skipped entirely when `system.debugMode` is `true`.** |
|
||||||
|
|
||||||
|
Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any authenticated call refreshes the timer. Permission levels: `None`, `bot`, `admin`. Endpoints requiring `bot` accept both `bot` and `admin` tokens. Currently registration/login always issue `admin`-level tokens.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Permission | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/api/user/login` | POST | None | User login |
|
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `{token, userID, username, level, registerDate}`. |
|
||||||
| `/api/user/register` | POST | None | First-time registration |
|
| `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `{token, userID, username, level}`. |
|
||||||
| `/api/server/list` | GET | bot/admin | List all servers |
|
| `/api/server/list` | GET | bot / admin | List all monitored servers. |
|
||||||
| `/api/server/getStatus` | GET | bot/admin | Get server recent status |
|
| `/api/server/getStatus` | GET | bot / admin | Recent live status for a server. Query `?uuid=<uuid>`. Returns `{uuid, report}` or `404`. |
|
||||||
| `/api/server/exec` | POST | bot/admin | Execute command on server(s) |
|
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `{taskID, results}`. |
|
||||||
| `/health` | GET | None | Health check |
|
| `/health` | GET | None | Health check. Returns `{status, database}`. |
|
||||||
| `/api/system/getLogs` | WS | None | Real-time log streaming |
|
| `/api/system/getLogs` | WebSocket | None | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. |
|
||||||
|
|
||||||
## Bot Commands
|
Middleware applied to the whole server:
|
||||||
|
|
||||||
|
- **Rate limit** — token bucket, 100 requests/minute per client IP.
|
||||||
|
- **CORS** — `Access-Control-Allow-Origin: *`, allows `Content-Type`, `X-Token`, `X-Timestamp`, `Authorization`.
|
||||||
|
- **Security headers** — `X-XSS-Protection`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, a restrictive CSP.
|
||||||
|
|
||||||
|
## Bots
|
||||||
|
|
||||||
|
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 (WebSocket event stream + HTTP actions); Telegram uses `go-telegram/bot` long polling.
|
||||||
|
|
||||||
|
### Permission model
|
||||||
|
|
||||||
|
- **Trusted group**: any command issued *inside a group* is only answered if that group is listed in `trustedGroups`. Messages in other groups are ignored.
|
||||||
|
- **Admin commands**: `/shutdown`, `/reboot`, and `/run` additionally require the *sender* to be listed in `admins`.
|
||||||
|
- **Private chat**: non-admin commands (`/help`, `/list`, `/status`, `/info`, `/getip`) are answered for any private sender; admin commands still require admin.
|
||||||
|
|
||||||
|
### Bot recognition modes
|
||||||
|
|
||||||
|
- `global` — the bot watches all messages in trusted groups and reacts to recognized commands without being mentioned. Unknown `/`-commands are silently ignored.
|
||||||
|
- `at` — the bot only reacts when it is mentioned (QQ `@`, Telegram `@botname`). In this mode an unknown command produces an `Unknown command: /…` reply.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
| Command | Permission | Description |
|
| Command | Permission | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/list` | None | List all server status |
|
| `/help` | All | Show the help message (`BOT_HELP` template). |
|
||||||
| `/shutdown <uuid>` | Admin | Shutdown specific server |
|
| `/list` | All | List all servers with online/offline state (`SERVER_LIST` template). |
|
||||||
| `/reboot <uuid>` | Admin | Reboot specific server |
|
| `/status <uuid>` | All | Live report for a server (CPU, RAM, disk, network, uptime, processes). |
|
||||||
| `/status <uuid>` | None | Get server detailed status |
|
| `/info <uuid>` | All | Static info for a server (OS, kernel, CPU, RAM, swap, disk, billing, tags). |
|
||||||
| `/run <uuid\|all> <command>` | Admin | Run command on server(s) |
|
| `/getip <uuid>` | All | IPv4 / IPv6 address of a server. |
|
||||||
|
| `/shutdown <uuid>` | Admin | Shut the server down via the Komari task API. |
|
||||||
|
| `/reboot <uuid>` | Admin | Reboot the server via the Komari task API. |
|
||||||
|
| `/run <uuid\|all> <command>` | Admin | Execute a command on one server or on all servers (`all`), then report the result (`SERVER_EXECUTE_RESULT` template). |
|
||||||
|
| `/start` | All | Telegram only — sends the `TG_BOT_START` welcome message. |
|
||||||
|
|
||||||
|
## Notification channels
|
||||||
|
|
||||||
|
QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **status-only** channels — they receive server status-change alerts but cannot run commands. On startup, the welcome message and initial server list are delivered only to the bot channels (QQ / Telegram), honoring each member's `event_bot_started` preference.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
Requires Go 1.25+. Repo includes helper scripts that bake the current git commit and build time into the binary via `-ldflags`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o nukumizu-backend .
|
# Windows (cross-compile to Linux amd64)
|
||||||
|
build-linux-x86_64.bat
|
||||||
|
|
||||||
|
# Windows amd64
|
||||||
|
build-win-x86_64.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Equivalent manual builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
-o nukumizu-linux-amd64 .
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
||||||
|
go build -ldflags "-X main.CommitHash=<commit> -X main.BuildTime=<utc-time>" \
|
||||||
|
-o nukumizu-windows-amd64.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./nukumizu-backend -config config.json
|
# Development (uses go run, so config files must be in the CWD)
|
||||||
|
run.bat
|
||||||
|
|
||||||
|
# Or build first, then run a binary
|
||||||
|
./nukumizu-linux-amd64 -config config.json -bot-user-config bot_user_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On startup the program logs in to Komari, loads node state, connects the status WebSocket, then starts each enabled controller and the HTTP server on `listenAddr:listenPort`. Press `Ctrl+C` for a graceful shutdown.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
@@ -1,11 +1,51 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||||
|
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||||
|
// other config files this one is auto-generated and optional: a missing or
|
||||||
|
// empty file yields an empty map, so every node falls back to its default
|
||||||
|
// enableStatusNotify value (true).
|
||||||
|
func LoadBotNodeConfig(configPath string) error {
|
||||||
|
cfg := BotNodeMembers{}
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
C_botNodeConfig = cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to read bot node config file: %w", err)
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(data)) > 0 {
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse bot node config file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
C_botNodeConfig = cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeStatusNotifyEnabled reports whether the node identified by uuid should
|
||||||
|
// broadcast status-change notifications, per bot_node_config.json.
|
||||||
|
// enableStatusNotify defaults to true: a node notifies unless its entry
|
||||||
|
// explicitly sets the flag to false.
|
||||||
|
func NodeStatusNotifyEnabled(uuid string) bool {
|
||||||
|
if C_botNodeConfig == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
opts, ok := C_botNodeConfig[uuid]
|
||||||
|
if !ok || opts.EnableStatusNotify == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *opts.EnableStatusNotify
|
||||||
|
}
|
||||||
|
|
||||||
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||||
// and stores it as a global singleton.
|
// and stores it as a global singleton.
|
||||||
func LoadGlobalConfig(configPath string) (*Config, error) {
|
func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||||
@@ -115,6 +155,46 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveBotNodeConfig persists the given node UUIDs to the bot node config file,
|
||||||
|
// which is a plain registry of the nodes Komari reports (read back at startup
|
||||||
|
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||||
|
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||||
|
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
||||||
|
// the first time are added as an empty object, i.e. no parameter is written, so
|
||||||
|
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||||
|
// its object keys emitted in sorted order by encoding/json, keeping the file
|
||||||
|
// deterministic across writes. The path is supplied by the caller (typically
|
||||||
|
// global.ConfigPath.BotNodeConfig).
|
||||||
|
func SaveBotNodeConfig(configPath string, uuids []string) error {
|
||||||
|
// Start from whatever is already on disk so nothing is dropped. An empty or
|
||||||
|
// missing file is treated as an empty map.
|
||||||
|
members := make(BotNodeMembers)
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 {
|
||||||
|
if err := json.Unmarshal(data, &members); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse existing bot node config %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add every currently-fetched UUID, but never overwrite an entry that is
|
||||||
|
// already configured.
|
||||||
|
for _, uuid := range uuids {
|
||||||
|
if _, ok := members[uuid]; !ok {
|
||||||
|
members[uuid] = BotNodeOptions{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(members, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal bot node config: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write bot node config %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsDebugMode returns whether debug mode is enabled.
|
// IsDebugMode returns whether debug mode is enabled.
|
||||||
func IsDebugMode() bool {
|
func IsDebugMode() bool {
|
||||||
if C_globalConfig == nil {
|
if C_globalConfig == nil {
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ type BotUserOptions struct {
|
|||||||
// EventStatusNotify indicates whether this member is subscribed to node
|
// EventStatusNotify indicates whether this member is subscribed to node
|
||||||
// status change notifications.
|
// status change notifications.
|
||||||
EventStatusNotify bool `json:"event_status_notify"`
|
EventStatusNotify bool `json:"event_status_notify"`
|
||||||
|
|
||||||
|
// EventBotStarted indicates whether this member receives the automatic
|
||||||
|
// messages the bot pushes on startup (welcome message and startup server
|
||||||
|
// list).
|
||||||
|
EventBotStarted bool `json:"event_bot_started"`
|
||||||
|
|
||||||
|
// EventReply indicates whether this member receives automatic replies to
|
||||||
|
// their commands (e.g. /status, /list). If false, the bot will not send any
|
||||||
|
// reply to this member's commands.
|
||||||
|
EventReply bool `json:"event_reply"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BotUserMembers maps a member ID (QQ number, Telegram @username or numeric
|
// BotUserMembers maps a member ID (QQ number, Telegram @username or numeric
|
||||||
@@ -160,3 +170,23 @@ type BotUserConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var C_botUserConfig *BotUserConfig
|
var C_botUserConfig *BotUserConfig
|
||||||
|
|
||||||
|
// BotNodeOptions holds per-node options stored in bot_node_config.json. The
|
||||||
|
// file is auto-populated by the node tracker for every node Komari reports;
|
||||||
|
// per-node options are edited by hand in the JSON file.
|
||||||
|
type BotNodeOptions struct {
|
||||||
|
// EnableStatusNotify controls whether this node broadcasts status-change
|
||||||
|
// notifications. It defaults to true: only an explicit false in the JSON
|
||||||
|
// file disables a node's notifications. The pointer (rather than a plain
|
||||||
|
// bool) lets an absent field be told apart from an explicit false, and
|
||||||
|
// omitempty keeps untouched nodes stored as {}.
|
||||||
|
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||||
|
// options.
|
||||||
|
type BotNodeMembers map[string]BotNodeOptions
|
||||||
|
|
||||||
|
// C_botNodeConfig is the global singleton mirroring bot_node_config.json,
|
||||||
|
// populated by LoadBotNodeConfig.
|
||||||
|
var C_botNodeConfig BotNodeMembers
|
||||||
@@ -22,3 +22,15 @@ var SoftwareInfo = SoftwareInfoStr{
|
|||||||
BuildType: "Debug",
|
BuildType: "Debug",
|
||||||
BuildTime: "unknown",
|
BuildTime: "unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfigPathStr struct {
|
||||||
|
Global string
|
||||||
|
BotUserConfig string
|
||||||
|
BotNodeConfig string
|
||||||
|
}
|
||||||
|
|
||||||
|
var ConfigPath = ConfigPathStr{
|
||||||
|
Global: "config.json",
|
||||||
|
BotUserConfig: "bot_user_config.json",
|
||||||
|
BotNodeConfig: "bot_node_config.json",
|
||||||
|
}
|
||||||
@@ -21,6 +21,37 @@ type Command struct {
|
|||||||
SenderID int64 // User ID of the sender
|
SenderID int64 // User ID of the sender
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message represents a message to be sent by a controller.
|
||||||
|
type Message struct {
|
||||||
|
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
||||||
|
Content string // The message content
|
||||||
|
ChatID int64 // Chat/group ID where the message should be sent
|
||||||
|
Type string // Message type (see MessageType*), used for per-member opt-outs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message type labels. They let bot pipes apply per-member opt-out options from
|
||||||
|
// bot_user_config.json (see MemberReceives) to automatic messages.
|
||||||
|
const (
|
||||||
|
// MessageTypeBotStarted marks the automatic welcome/server-list messages the
|
||||||
|
// bot pushes on startup. Gated by BotUserOptions.EventBotStarted.
|
||||||
|
MessageTypeBotStarted = "event_bot_started"
|
||||||
|
// MessageTypeReply marks a direct reply to a user command. Reserved for the
|
||||||
|
// BotUserOptions.EventReply opt-out.
|
||||||
|
MessageTypeReply = "event_reply"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemberReceives reports whether a member whose bot_user_config.json options are
|
||||||
|
// opts receives an automatic message of the given type. Only member-controllable
|
||||||
|
// types are gated; anything else is always delivered.
|
||||||
|
func MemberReceives(opts config.BotUserOptions, messageType string) bool {
|
||||||
|
switch messageType {
|
||||||
|
case MessageTypeBotStarted:
|
||||||
|
return opts.EventBotStarted
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Controller defines the interface for all notification/bot controllers.
|
// Controller defines the interface for all notification/bot controllers.
|
||||||
type Controller interface {
|
type Controller interface {
|
||||||
Name() string
|
Name() string
|
||||||
@@ -38,7 +69,7 @@ type Controller interface {
|
|||||||
// pipes (email, ntfy, webhook) do not.
|
// pipes (email, ntfy, webhook) do not.
|
||||||
type BotController interface {
|
type BotController interface {
|
||||||
Controller
|
Controller
|
||||||
SendMessage(message string) error
|
SendMessage(message Message) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager manages all controller instances and routes events.
|
// Manager manages all controller instances and routes events.
|
||||||
@@ -72,14 +103,16 @@ func (m *Manager) Register(c Controller) {
|
|||||||
|
|
||||||
// ShowBotInitMessage sends the bot initialization message to all enabled
|
// ShowBotInitMessage sends the bot initialization message to all enabled
|
||||||
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
||||||
// not implement BotController are skipped.
|
// not implement BotController are skipped. The message is typed
|
||||||
|
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
||||||
|
// EventBotStarted opt-out.
|
||||||
func (m *Manager) ShowBotInitMessage() {
|
func (m *Manager) ShowBotInitMessage() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.C_globalConfig
|
||||||
params := template.BuildBotInitializationMsgParams()
|
params := template.BuildBotInitializationMsgParams()
|
||||||
message := template.Render(cfg.ControllerMessage.BotStarted, params)
|
content := template.Render(cfg.ControllerMessage.BotStarted, params)
|
||||||
|
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
if !ctrl.IsEnabled() {
|
if !ctrl.IsEnabled() {
|
||||||
@@ -89,22 +122,29 @@ func (m *Manager) ShowBotInitMessage() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
||||||
}
|
}
|
||||||
|
message := Message{
|
||||||
|
Source: bot.Name(),
|
||||||
|
Content: content,
|
||||||
|
Type: MessageTypeBotStarted,
|
||||||
|
}
|
||||||
if err := bot.SendMessage(message); err != nil {
|
if err := bot.SendMessage(message); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err))
|
postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowBotServerList sends the server list to all enabled bot controllers. The
|
// ShowBotServerList sends the startup server list to all enabled bot
|
||||||
// message content is identical to the /list command (same template and
|
// controllers. The message content is identical to the /list command (same
|
||||||
// parameters).
|
// template and parameters). Like the init message it is typed
|
||||||
|
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
|
||||||
|
// receive it.
|
||||||
func (m *Manager) ShowBotServerList() {
|
func (m *Manager) ShowBotServerList() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.C_globalConfig
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
content := template.Render(cfg.ControllerMessage.ServerList, params)
|
||||||
|
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
if !ctrl.IsEnabled() {
|
if !ctrl.IsEnabled() {
|
||||||
@@ -114,32 +154,37 @@ func (m *Manager) ShowBotServerList() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
|
||||||
}
|
}
|
||||||
|
message := Message{
|
||||||
|
Source: bot.Name(),
|
||||||
|
Content: content,
|
||||||
|
Type: MessageTypeBotStarted,
|
||||||
|
}
|
||||||
if err := bot.SendMessage(message); err != nil {
|
if err := bot.SendMessage(message); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err))
|
postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyStatusChange sends a status change notification to all enabled controllers.
|
// NotifyStatusChange sends a status change notification to all enabled
|
||||||
|
// controllers. It honors the per-node allow-list in bot_node_config.json: a
|
||||||
|
// node whose enableStatusNotify is not true is skipped entirely, so no
|
||||||
|
// controller (chat bots or notification pipes) broadcasts its change.
|
||||||
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
||||||
|
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
||||||
|
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
|
||||||
templateStr := cfg.ControllerMessage.ServerStatusChanged
|
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
|
||||||
|
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
if !ctrl.IsEnabled() {
|
if !ctrl.IsEnabled() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the names of controllers that support commands for the message.
|
|
||||||
if err := ctrl.SendStatusChange(change); err != nil {
|
if err := ctrl.SendStatusChange(change); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Controller %s failed to send status change: %v", ctrl.Name(), err))
|
postLog.Warning(fmt.Sprintf("Controller %s failed to send status change: %v", ctrl.Name(), err))
|
||||||
}
|
}
|
||||||
_ = templateStr
|
|
||||||
_ = params
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,18 +221,29 @@ func (q *QQController) trustedGroupIDs() []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
|
// SendMessage sends an automatic message (e.g. the bot initialization message
|
||||||
// to all QQ trusted groups and admins.
|
// and the startup server list) to all QQ trusted groups and admins. Each
|
||||||
func (q *QQController) SendMessage(message string) error {
|
// member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
|
||||||
|
// event_bot_started messages) are honored per recipient.
|
||||||
|
func (q *QQController) SendMessage(message controller.Message) error {
|
||||||
if !q.cfg.Enabled {
|
if !q.cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, groupID := range q.trustedGroupIDs() {
|
// Only notify trusted groups and admins whose options allow this message type.
|
||||||
q.sendGroupMessage(groupID, message)
|
if uc := config.C_botUserConfig; uc != nil {
|
||||||
|
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||||
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q.sendGroupMessage(groupID, message.Content)
|
||||||
|
}
|
||||||
|
for adminID, opts := range uc.QQ.Admins {
|
||||||
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q.sendPrivateMessage(adminID, message.Content)
|
||||||
}
|
}
|
||||||
for _, adminID := range q.adminIDs() {
|
|
||||||
q.sendPrivateMessage(adminID, message)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -247,15 +258,21 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error {
|
|||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||||
|
|
||||||
// Send to trusted groups.
|
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||||
for _, groupID := range q.trustedGroupIDs() {
|
if uc := config.C_botUserConfig; uc != nil {
|
||||||
|
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||||
|
if !opts.EventStatusNotify {
|
||||||
|
continue
|
||||||
|
}
|
||||||
q.sendGroupMessage(groupID, message)
|
q.sendGroupMessage(groupID, message)
|
||||||
}
|
}
|
||||||
|
for adminID, opts := range uc.QQ.Admins {
|
||||||
// Send to admins via private message.
|
if !opts.EventStatusNotify {
|
||||||
for _, adminID := range q.adminIDs() {
|
continue
|
||||||
|
}
|
||||||
q.sendPrivateMessage(adminID, message)
|
q.sendPrivateMessage(adminID, message)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-telegram/bot"
|
"github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
"nukumizu-backend/internal/controller"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxMessageLen is the safe chunk size for outbound messages. Telegram's hard
|
// maxMessageLen is the safe chunk size for outbound messages. Telegram's hard
|
||||||
@@ -18,15 +19,16 @@ const maxMessageLen = 4000
|
|||||||
// rich text. Templates must stay valid under Telegram's legacy Markdown:
|
// rich text. Templates must stay valid under Telegram's legacy Markdown:
|
||||||
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
|
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
|
||||||
// API reject the whole message.
|
// API reject the whole message.
|
||||||
func (t *TelegramController) sendMessage(chatID int64, text string) error {
|
func (t *TelegramController) sendMessage(message controller.Message) error {
|
||||||
if t.client == nil {
|
if t.client == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(text) == "" {
|
if strings.TrimSpace(message.Content) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, chunk := range splitMessage(text, maxMessageLen) {
|
|
||||||
if err := t.sendMessageChunk(chatID, chunk); err != nil {
|
for _, chunk := range splitMessage(message.Content, maxMessageLen) {
|
||||||
|
if err := t.sendMessageChunk(message.ChatID, chunk); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,14 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.sendMessage(msg.Chat.ID, response); err != nil {
|
message := controller.Message{
|
||||||
|
Source: "telegram",
|
||||||
|
Content: response,
|
||||||
|
ChatID: msg.Chat.ID,
|
||||||
|
Type: controller.MessageTypeReply,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.sendMessage(message); err != nil {
|
||||||
postLog.Warning("Failed to send Telegram reply: " + err.Error())
|
postLog.Warning("Failed to send Telegram reply: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,13 +236,30 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
|
// SendMessage sends an automatic message (e.g. the bot initialization message
|
||||||
// to all Telegram trusted groups and admins.
|
// and the startup server list) to all Telegram trusted groups and admins. Each
|
||||||
func (t *TelegramController) SendMessage(message string) error {
|
// member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
|
||||||
|
// event_bot_started messages) are honored per recipient.
|
||||||
|
func (t *TelegramController) SendMessage(message controller.Message) error {
|
||||||
if !t.cfg.Enabled || t.client == nil {
|
if !t.cfg.Enabled || t.client == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
t.sendToGroupsAndAdmins(message)
|
|
||||||
|
// Only notify trusted groups and admins whose options allow this message type.
|
||||||
|
if uc := config.C_botUserConfig; uc != nil {
|
||||||
|
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||||
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.sendGroupMessage(groupID, message.Content)
|
||||||
|
}
|
||||||
|
for admin, opts := range uc.Telegram.Admins {
|
||||||
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.sendAdminMessage(admin, message.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +273,21 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
|
|||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
||||||
|
|
||||||
t.sendToGroupsAndAdmins(message)
|
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||||
|
if uc := config.C_botUserConfig; uc != nil {
|
||||||
|
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||||
|
if !opts.EventStatusNotify {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.sendGroupMessage(groupID, message)
|
||||||
|
}
|
||||||
|
for admin, opts := range uc.Telegram.Admins {
|
||||||
|
if !opts.EventStatusNotify {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.sendAdminMessage(admin, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,14 +406,6 @@ func (t *TelegramController) resolvedAdminList() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendToGroupsAndAdmins sends a message to all trusted groups and admins.
|
|
||||||
func (t *TelegramController) sendToGroupsAndAdmins(message string) {
|
|
||||||
t.sendToGroups(message)
|
|
||||||
for _, admin := range t.adminIDs() {
|
|
||||||
t.sendAdminMessage(admin, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendToGroups sends a message to all trusted groups.
|
// sendToGroups sends a message to all trusted groups.
|
||||||
func (t *TelegramController) sendToGroups(message string) {
|
func (t *TelegramController) sendToGroups(message string) {
|
||||||
for _, groupID := range t.trustedGroupIDs() {
|
for _, groupID := range t.trustedGroupIDs() {
|
||||||
@@ -412,7 +442,13 @@ func (t *TelegramController) sendAdminMessage(admin string, message string) {
|
|||||||
|
|
||||||
// sendToChat sends a message to a chat ID, logging failures.
|
// sendToChat sends a message to a chat ID, logging failures.
|
||||||
func (t *TelegramController) sendToChat(chatID int64, message string) {
|
func (t *TelegramController) sendToChat(chatID int64, message string) {
|
||||||
if err := t.sendMessage(chatID, message); err != nil {
|
messaged := controller.Message{
|
||||||
|
Source: "telegram",
|
||||||
|
Content: message,
|
||||||
|
ChatID: chatID,
|
||||||
|
Type: controller.MessageTypeReply,
|
||||||
|
}
|
||||||
|
if err := t.sendMessage(messaged); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err))
|
postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
func Contains(slice []string, target string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/global"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
)
|
)
|
||||||
@@ -225,6 +226,20 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
|
|||||||
nodes = append(nodes, n)
|
nodes = append(nodes, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the fetched node UUIDs to bot_node_config.json on every fetch
|
||||||
|
// (startup login, WebSocket reconnect and periodic refresh all funnel
|
||||||
|
// through FetchNodes) so the file always reflects the nodes Komari
|
||||||
|
// currently manages. A persistence failure is only logged: the node data
|
||||||
|
// itself was fetched successfully and must not be discarded over a disk
|
||||||
|
// write problem.
|
||||||
|
uuids := make([]string, len(nodes))
|
||||||
|
for i, n := range nodes {
|
||||||
|
uuids[i] = n.UUID
|
||||||
|
}
|
||||||
|
if err := config.SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, uuids); err != nil {
|
||||||
|
postLog.Warning("Failed to save bot node config: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,25 +33,27 @@ func main() {
|
|||||||
global.SoftwareInfo.CommitHash = CommitHash
|
global.SoftwareInfo.CommitHash = CommitHash
|
||||||
global.SoftwareInfo.BuildTime = BuildTime
|
global.SoftwareInfo.BuildTime = BuildTime
|
||||||
|
|
||||||
// Parse CLI flags.
|
|
||||||
configPath_global := flag.String("config", "config.json", "Path to configuration file")
|
|
||||||
configPath_bot_user := flag.String("bot-user-config", "bot_user_config.json", "Path to bot user configuration file")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// Log startup banner.
|
// Log startup banner.
|
||||||
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", global.SoftwareInfo.Name, global.SoftwareInfo.Version, global.SoftwareInfo.BuildVer, global.SoftwareInfo.BuildType, global.SoftwareInfo.CommitHash, global.SoftwareInfo.Developer, global.SoftwareInfo.BuildTime))
|
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", global.SoftwareInfo.Name, global.SoftwareInfo.Version, global.SoftwareInfo.BuildVer, global.SoftwareInfo.BuildType, global.SoftwareInfo.CommitHash, global.SoftwareInfo.Developer, global.SoftwareInfo.BuildTime))
|
||||||
|
|
||||||
// Load configuration.
|
// Load configuration.
|
||||||
cfg, err := config.LoadGlobalConfig(*configPath_global)
|
cfg, err := config.LoadGlobalConfig(global.ConfigPath.Global)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load global config: %v", err)
|
log.Fatalf("Failed to load global config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = config.LoadBotUserConfig(*configPath_bot_user)
|
_, err = config.LoadBotUserConfig(global.ConfigPath.BotUserConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load bot user config: %v", err)
|
log.Fatalf("Failed to load bot user config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the node registry config. Unlike the other files it is optional:
|
||||||
|
// missing or empty bot_node_config.json simply means every node keeps its
|
||||||
|
// default enableStatusNotify (true).
|
||||||
|
if err := config.LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||||
|
log.Fatalf("Failed to load bot node config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize logging.
|
// Initialize logging.
|
||||||
postLog.SetDebugMode(cfg.System.DebugMode)
|
postLog.SetDebugMode(cfg.System.DebugMode)
|
||||||
postLog.InitLogBroadcaster()
|
postLog.InitLogBroadcaster()
|
||||||
|
|||||||
Reference in New Issue
Block a user