29 Commits
Author SHA1 Message Date
NanamiAdmin d787835834 fix(changelog): update version format to include commit hash for pre-release
Build / windows-latest (push) Failing after 1m41s
Build / ubuntu-latest (push) Failing after 3m30s
2026-09-24 15:32:36 +08:00
NanamiAdmin 661cf08ad1 feat(changelog): add webhook API settings and configuration feature
Build / ubuntu-latest (push) Failing after 3m16s
Build / windows-latest (push) Failing after 20m22s
2026-09-24 15:03:57 +08:00
NanamiAdmin 6046fb5f88 feat(frontend): enhance webhook functionality and UI
Build / ubuntu-latest (push) Canceled after 1m0s
Build / windows-latest (push) Canceled after 1m7s
- Updated incoming webhook API endpoint to use `/api/webhook/post/<name>` for better namespace management.
- Added a new WebHooks page in the admin UI for managing webhook endpoints.
- Introduced a ConfigSection component to streamline the rendering and saving of configuration fields.
- Enhanced Settings.vue to reference the new WebHooks page and updated the configuration structure.
- Implemented a new webhook API in the frontend to handle listing, adding, modifying, and removing webhook endpoints.
- Improved the sidebar to include a link to the WebHooks page.
- Added functionality to generate tokens for new webhook endpoints and manage notification channels.
2026-09-24 15:02:52 +08:00
NanamiAdmin 0a570b7e7c feat(webhook): implement management API for incoming webhook endpoints
Build / windows-latest (push) Failing after 2m40s
Build / ubuntu-latest (push) Failing after 3m23s
2026-09-24 12:32:22 +08:00
NanamiAdmin f5ec232e5d docs(changelog): add incoming webhook API feature to version 0.2.0.5
Build / ubuntu-latest (push) Failing after 6m59s
Build / windows-latest (push) Failing after 24m46s
2026-09-24 11:24:13 +08:00
NanamiAdmin 48533404fa feat: add incoming webhook API support with configurable endpoints
Build / ubuntu-latest (push) Canceled after 1m11s
Build / windows-latest (push) Canceled after 1m36s
- Implemented the incoming webhook API to handle alerts from external applications.
- Added configuration options for webhook listening address, port, and endpoints in config.go.
- Created WebhookReceiverConfig and WebhookEndpointConfig structures to manage webhook settings.
- Developed WebhookHandler to process incoming requests, validate tokens, and deliver alerts to specified channels.
- Enhanced existing controller interfaces to support alert delivery.
- Updated message rendering to respect Markdown settings for different channels.
- Added tests for webhook functionality and ensured proper error handling.
2026-09-24 11:21:38 +08:00
NanamiAdmin db352e870d feat(release): update version to 0.2.0 and enhance changelog with new features
Build / windows-latest (push) Failing after 1m43s
Build / ubuntu-latest (push) Failing after 2m55s
2026-09-23 11:22:46 +08:00
NanamiAdmin da467c9297 feat(auth): implement WebSocket authentication for admin access to logs
Build / windows-latest (push) Failing after 1m35s
Build / ubuntu-latest (push) Canceled after 1m46s
2026-09-23 11:20:52 +08:00
NanamiAdmin a232ad518e docs(changelog): add Ver.0.1.2.4-a6f3107.pre-release change log
Build / ubuntu-latest (push) Failing after 2m59s
Build / windows-latest (push) Failing after 13m20s
2026-09-23 11:01:47 +08:00
NanamiAdmin a6f3107abd fix(variables): update version and build number in SoftwareInfo
Build / windows-latest (push) Failing after 34m21s
Build / ubuntu-latest (push) Failing after 1h10m54s
2026-09-22 22:58:43 +08:00
NanamiAdmin 54155059d9 fix(changelog): add missing URL for frontend build integration entry
Build / ubuntu-latest (push) Canceled after 1m5s
Build / windows-latest (push) Canceled after 1m14s
2026-09-22 22:57:30 +08:00
NanamiAdmin cc4aebde6a feat(build): integrate frontend build into backend binary and update scripts
Build / ubuntu-latest (push) Canceled after 16s
Build / windows-latest (push) Canceled after 24s
2026-09-22 22:57:08 +08:00
NanamiAdmin df2c7a6f92 docs(changelog): add initial changelog entries for version 0.1.2.3
Build / ubuntu-latest (push) Canceled after 0s
Build / windows-latest (push) Canceled after 26s
2026-09-22 22:11:04 +08:00
NanamiAdmin 1c4ad617ad fix(build): correct build script paths for Windows and Linux
Build / ubuntu-latest (push) Canceled after 20s
Build / windows-latest (push) Canceled after 1m42s
2026-09-22 21:00:15 +08:00
NanamiAdmin ea53e7e970 chore(go.mod): update Go version to 1.27.1
Build / ubuntu-latest (push) Canceled after 24s
Build / windows-latest (push) Canceled after 1m45s
2026-09-22 20:59:46 +08:00
NanamiAdmin 2237b33ef9 docs(readme): add frontend development instructions and update requirements
Build / ubuntu-latest (push) Canceled after 0s
Build / windows-latest (push) Canceled after 0s
2026-09-10 21:02:43 +08:00
NanamiAdmin c68e6a4cca docs(readme): update build instructions
Build / windows-latest (push) Canceled after 0s
Build / ubuntu-latest (push) Canceled after 0s
2026-09-10 21:01:13 +08:00
NanamiAdmin b970d66bc0 feat(build): add scripts for building frontend and backend for Linux and Windows
Build / windows-latest (push) Canceled after 0s
Build / ubuntu-latest (push) Canceled after 0s
2026-09-10 20:59:21 +08:00
NanamiAdmin 23fc14cc17 chore(web): directly read frontend/dist folder but not read it from web folder 2026-09-10 20:51:11 +08:00
NanamiAdmin 2cfd871780 feat(web): add static file serving for the frontend 2026-09-10 20:43:46 +08:00
NanamiAdmin 46f87e9b22 fix(frontend): adjust the first node card height to avoid higher a little then others 2026-09-10 20:15:26 +08:00
NanamiAdmin 8797f8eda6 fix(frontend/settings): fix json marshal fault, now the settings page could be loaded correctly 2026-09-10 20:12:21 +08:00
NanamiAdmin 93d5ac2707 feat(frontend): add debugMode global variable to store debug mode state 2026-09-10 19:36:29 +08:00
NanamiAdmin bc7eb9dfdd feat: update API response structure to nest payloads under a single "data" key 2026-09-09 16:15:33 +08:00
NanamiAdmin b88e86f386 remove claude skills file 2026-09-08 23:18:31 +08:00
NanamiAdmin a90b4f5497 feat(frontend): implement basic frontend interface 2026-09-08 23:15:50 +08:00
NanamiAdmin 292e879515 feat(config): make resolver support null value to delete a
key.
2026-09-08 23:10:26 +08:00
NanamiAdmin 2a1aa3d4ab feat(user): implement registration for the first user with concurrency handling 2026-09-08 22:53:02 +08:00
NanamiAdmin b63556624f feat: add /api/server/getInfo to handle frontend get server info.
chore: rebuild `/api/server/getStatus` to the same logic as `getInfo`.
2026-09-08 22:28:49 +08:00
48 changed files with 805 additions and 4195 deletions

No files matched your search

-4
View File
@@ -6,10 +6,6 @@ bot_node_config.json
*.exe
nukumizu-linux-amd64
# Scratch test files dropped in the repo root stay untracked; the real suite
# lives next to the code it covers (config/, handler/, ...) and is tracked.
/*test.go
# The built web console. web/embed.go compiles it into the binary, and the
# build-*.sh / build-*.bat scripts rebuild it before every compile.
/web/dist/
+67 -138
View File
@@ -1,53 +1,42 @@
# Nukumizu Backend
English | [简体中文](README.zh-CN.md)
Remote server monitoring and command execution subsystem for [Komari](https://www.komari.wiki).
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/) or any OneBot 11 implementation) and Telegram so operators can control servers from chat. A Vue console embedded in the binary provides the same overview and settings from a browser.
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.
## Features
- **Komari integration** — logs into the Komari Dashboard, refreshing the node list on startup and every 5 minutes, and pulls each node's latest status over the Komari WebSocket every 5 seconds.
- **Resilient upstream access** — login and node fetching are retried up to 6 attempts with linear backoff (0.5s × attempt) on transport errors, `429` and `5xx`. Credential failures, `4xx` responses and JSON-RPC errors fail immediately instead of hammering the dashboard.
- **Status tracking** — a thread-safe node tracker keeps the latest report and static info per server and detects `Online` / `Offline` transitions. The first snapshot after startup is treated as a baseline, so a restart does not produce a wave of false "offline" alerts.
- **Per-node notification control** — `bot_node_config.json` can silence status pushes for an individual node, and can hold an `Offline` notification for a configurable window so a quick reconnect produces no alert at all (flap inhibition).
- **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 (OneBot 11, e.g. NapCat) and Telegram bots for `/list`, `/status`, `/info`, `/run`, `/shutdown`, `/reboot`, and more, protected by an admin / trusted-group permission model.
- **Notification channels** — status changes are pushed to every enabled channel: QQ, Telegram, Email (SMTP), [ntfy](https://ntfy.sh), and Webhook.
- **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.
- **Incoming webhook API** — external applications can push their own alerts in via `POST /api/webhook/post/<name>`, and Nukumizu relays them to the channels that endpoint lists. Each endpoint carries its own token and target channels, and the API is served on a **separate listener** so it can be exposed without exposing the admin API.
- **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`, with Markdown formatting switched on per channel.
- **Hot reload** — saving settings applies almost everything immediately; the few keys that are only read at startup are reported back as `restartRequired`.
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; the rollback journal is used instead of WAL so the files stay safe on network shares.
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
- **Dashboard API** — token-authenticated REST API plus an admin-only live log-streaming WebSocket.
- **Web console** — a Vue 3 admin UI for browsing nodes and their per-node options, editing settings, managing bot trust and webhook endpoints, and tailing logs. The built bundle is embedded in the binary, so a single executable serves both the API and the console.
- **Tests** — the Go packages ship with unit tests (`go test ./...`), including the retry, flap-inhibition and settings-merge paths.
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust and webhook endpoints, and tailing logs. The built bundle is embedded in the binary, so a single executable serves both the API and the console.
## How it works
1. On startup Nukumizu loads `config.json`, `bot_user_config.json` and (optionally) `bot_node_config.json`, then logs in to the Komari Dashboard. Login retries transport errors / `429` / `5xx` up to 6 times; a login that still fails aborts the process.
2. It fetches the node list (name → UUID, static info) and stores it in memory, then opens a WebSocket to `/api/rpc2` and requests the latest status every 5 seconds. If the connection drops it reconnects with exponential backoff (1s, 2s, 4s … capped at 30s); after 5 failed attempts it logs an error and keeps retrying every 30 seconds.
3. The node tracker waits for the initial snapshot covering every known node before enabling notifications (with a 2-minute safety net), and always treats the very first snapshot as the startup baseline — no alerts are generated for the state found at boot.
4. Each real transition is rendered through the `SERVER_STATUS_CHANGED` template and sent to all enabled controllers. Before that, the per-node gates from `bot_node_config.json` are applied: `enableStatusNotify` can drop the change entirely, and `flap_tolerant` holds an `Offline` for N seconds — if the node returns in time, both the `Offline` and the `Online` are suppressed.
5. QQ / Telegram additionally receive a startup welcome message and the initial server list once the first refresh completes, honoring each member's `event_bot_started` preference.
6. Every 12 hours the Komari session is re-authenticated; the node list is refreshed every 5 minutes.
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/
├── main.go # Entry point, startup sequence, background tasks, graceful shutdown
├── main.go # Entry point, startup sequence, graceful shutdown
├── router.go # HTTP route registration (main + webhook API)
├── config/
│ ├── config.go # Load config files, apply defaults, per-node helpers
│ ├── variables.go # Config schema structs + atomic config singletons
│ ├── settings.go # Settings file read/merge/write, startup-only key detection
│ ├── webhook.go # Incoming webhook endpoint helpers (add/modify/delete)
│ ├── reload.go # Reload hooks (logger, controller rebuild)
│ └── *_test.go # Settings merge, concurrency and flap tests
│ ├── config.go # Load config files, apply defaults
│ └── variables.go # Config schema structs + globals
├── global/
│ └── variables.go # Build metadata (name/version/developer) + config paths
│ └── variables.go # Software build metadata (name/version/developer)
├── handler/
│ ├── user.go # /api/user/login, /api/user/register
│ ├── server.go # /api/server/list, getInfo, getStatus, exec
@@ -59,7 +48,7 @@ nukumizu-backend/
│ └── user.go # user.db (SQLite) user store
├── utils/
│ ├── auth.go # Token management, Auth middleware, JSON responses
│ └── middleware.go # Rate limit, CORS, security headers
│ └── middleware.go # Rate limit, CORS, XSS headers, WebSocket auth
├── postLog/ # Logging subsystem
│ ├── postLog.go # Leveled logger (stdout + broadcast)
│ ├── database.go # log.db (SQLite, one table per run)
@@ -74,13 +63,13 @@ nukumizu-backend/
│ │ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
│ │ └── ws.go # Komari status WebSocket (poll + reconnect)
│ ├── node/
│ │ └── tracker.go # Thread-safe server state, baseline gating, transitions
│ │ └── tracker.go # Thread-safe server state, status-change detection
│ ├── netproxy/
│ │ └── netproxy.go # Unified network proxy for controllers
│ ├── template/
│ │ └── template.go # Message template renderer ({{ variables }})
│ └── controller/
│ ├── controller.go # Manager, interfaces, alert routing, flap inhibition
│ ├── controller.go # Manager, Controller / BotController interfaces, alerts
│ ├── trigger.go # Command parsing, authorization, routing
│ ├── processor.go # Command handlers
│ ├── utils.go
@@ -88,9 +77,9 @@ nukumizu-backend/
│ ├── email.go # Email notification pipe
│ ├── ntfy.go # ntfy notification pipe
│ ├── webhook.go # Outgoing webhook notification pipe
│ ├── qq_onebot/
│ │ ├── qq.go # QQ controller (OneBot 11, e.g. NapCat)
│ │ └── onebot.go # OneBot 11 WebSocket client (events + actions)
│ ├── 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)
@@ -102,33 +91,31 @@ nukumizu-backend/
├── App.vue # Root component + toast host
├── api/index.js # Wrappers around the REST endpoints
├── router/index.js # Routes and the login guard
├── styles/ # theme.css + ui.css
├── utils/ # http/auth/theme/toast/format/runtime helpers
├── components/ # Modal, Toggle, editors, ConfigSection, top bar, side bar
└── views/ # Login, Layout, Overview, Trusted, Settings, WebHooks, Logs
└── views/ # Login, Overview, Trusted, Settings, WebHooks, Logs
```
## Requirements
- Go **1.27.3** or newer (see `go.mod`)
- Go **1.25** or newer
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
- For QQ: an OneBot 11 implementation exposing a forward WebSocket endpoint, e.g. [NapCat](https://napneko.github.io/) (`/api` and `/event`)
- 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
Three JSON files are read from the working directory. **There are no command-line flags**: the paths are fixed (`config.json`, `bot_user_config.json`, `bot_node_config.json`), so start the program from the directory that holds them.
There are two configuration files, both read from the working directory unless overridden:
| File | Required | Purpose |
|---|---|---|
| `config.json` | Yes | Core settings: system, debug switches, Komari, webhook listener, controllers, message templates, storage paths |
| `bot_user_config.json` | Yes | Per-bot admins / trusted groups and their notification preferences |
| `bot_node_config.json` | No | Per-node options (notification switch, flap window). Auto-created on the first node fetch |
| 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 |
> All three files are in `.gitignore` because they contain credentials (Komari password, bot tokens, proxy auth). Start from the samples below and never commit real secrets. `bot_node_config.json` is safe to keep, but it is ignored along with the others.
> 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`
@@ -141,13 +128,12 @@ Three JSON files are read from the working directory. **There are no command-lin
"networkProxy": "http://127.0.0.1:7890"
},
"debug": {
"showOneBotMsg": false,
"showOneBotAction": false,
"showNapcatMsg": false,
"showNapcatAction": false,
"showTelegramMsg": false,
"showTriggerCmdEcho": true,
"showKomariTaskEcho": false,
"oneBotIgnoreSelfMsg": false,
"showFlapTolerantEcho": false
"napcatIgnoreSelfMsg": false
},
"komari": {
"dashboardURL": "https://status.example.com",
@@ -164,18 +150,18 @@ Three JSON files are read from the working directory. **There are no command-lin
"example": {
"enabled": true,
"token": "CHANGE_ME",
"notifyPipes": ["qq_onebot", "telegram", "email", "ntfy"]
"notifyPipes": ["qq(napcat)", "telegram", "email", "ntfy"]
}
}
},
"controllerMethod": {
"qq_onebot": {
"qq(napcat)": {
"enabled": false,
"markdown": false,
"networkUseProxy": false,
"oneBotAddr": "127.0.0.1",
"oneBotPort": "3000",
"oneBotToken": "",
"napcatAddr": "127.0.0.1",
"napcatPort": "3000",
"napcatToken": "",
"botQQID": 0,
"listenMethod": "global"
},
@@ -232,21 +218,14 @@ Three JSON files are read from the working directory. **There are no command-lin
Field notes:
- `controllerMethod` is keyed by channel name: `qq_onebot`, `telegram`, `email`, `ntfy`, `webhook`. The same names are used in `notifyPipes` and in `bot_user_config.json`. The QQ channel speaks OneBot 11, so `oneBotAddr` / `oneBotPort` / `oneBotToken` point at the OneBot implementation (NapCat by default on `127.0.0.1:3000`).
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, the QQ OneBot WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
- `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).
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
- `controllerMethod.qq_onebot.listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
- `debug` toggles verbose per-module logging; these only matter when `system.debugMode` is `true`:
- `showOneBotMsg` / `showOneBotAction` — incoming OneBot messages / outgoing OneBot API calls.
- `showTelegramMsg` — incoming Telegram updates.
- `showTriggerCmdEcho` — the parsed command handed to the trigger.
- `showKomariTaskEcho` — Komari task creation and polling.
- `oneBotIgnoreSelfMsg` — ignore the bot's own messages echoed back by OneBot.
- `showFlapTolerantEcho` — the flap-inhibition decisions (held / released / reported).
- `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.
- `dbPath` defaults to `./db`; `user.db` and `log.db` are created under it. `dataPath` defaults to `./data` and is reserved for future file storage — nothing is written there yet.
- Missing keys fall back to built-in defaults (host `0.0.0.0`, port `8080`, webhook API `0.0.0.0:8081`, no webhook endpoints, OneBot `127.0.0.1:3000`, ntfy server `https://ntfy.sh`, webhook method `POST`, etc.). Message templates have built-in fallbacks too. `markdown` defaults to `false`, so add it explicitly for Telegram (see the sample above) to keep its formatting.
- `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`, webhook API `0.0.0.0:8081`, no webhook endpoints, NapCat `127.0.0.1:3000`, ntfy server `https://ntfy.sh`, webhook method `POST`, etc.). Message templates have built-in fallbacks too. `markdown` defaults to `false`, so add it explicitly for Telegram (see the sample above) to keep its formatting.
### `bot_user_config.json`
@@ -254,7 +233,7 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
```json
{
"qq_onebot": {
"qq(napcat)": {
"admins": {
"123456789": {
"event_status_notify": true,
@@ -293,31 +272,10 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
- **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 messages (welcome + initial server list).
- `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.
### `bot_node_config.json`
Per-node options, keyed by the node UUID that Komari reports:
```json
{
"0f2a9c1e-1234-4a5b-8c9d-000000000001": {
"enableStatusNotify": false,
"flap_tolerant": 30
},
"7d3b8f20-5678-4c9d-9e0f-000000000002": {}
}
```
| Field | Default | Meaning |
|---|---|---|
| `enableStatusNotify` | `true` | When `false`, **no** status-change notification is sent for this node — through chat bots and notification pipes alike. Omit it (or the whole entry) to keep notifications on. |
| `flap_tolerant` | `0` | Seconds an `Offline` notification is held (float allowed, capped at 86400). If the node comes back within the window, both the `Offline` and the `Online` are suppressed; if it is still down when the window elapses, the `Offline` is sent then. `0` or an absent field notifies immediately. |
The file is auto-generated: every node fetch (`FetchNodes` — startup, reconnect and the 5-minute refresh) adds newly seen UUIDs as `{}` and never overwrites an existing entry, so hand-written options survive. Missing or empty files are fine — every node then keeps the defaults above. The **Nodes** page of the console edits both options, or use `POST /api/settings/set?type=bot_node_config`.
### Message templates
`controllerMessage` templates are rendered before sending. Available variables (channels with `markdown: true` additionally wrap the verbatim values in Markdown — see the field notes above):
@@ -336,18 +294,6 @@ The file is auto-generated: every node fetch (`FetchNodes` — startup, reconnec
| `{{ 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) |
### What applies without a restart
Saving settings applies most of them immediately. How each group takes effect:
| Settings | How it applies |
|---|---|
| `controllerMethod` (all five channels) | Every channel is **rebuilt**: the running controllers are stopped and a fresh set is built from the new settings. A channel that owns a connection reconnects — Telegram re-runs its `getMe` handshake, OneBot opens a new WebSocket — so notifications sent during the swap are lost. The rebuild only happens when this section actually changed; saving a message template does not disturb the channels. |
| `controllerMessage`, `debug`, `bot_user_config.json`, `bot_node_config.json`, `webhook.endpoints` | Picked up as they are used; nothing is restarted. A flap window or notification switch edited while an `Offline` is held is re-read when the hold fires. |
| `system.debugMode` | Applies to both behavior and log filtering. |
| `system.networkProxy` | Read on every request and every dial, so a new address reaches channels that are already running. Only the per-channel `networkUseProxy` opt-in is fixed when a channel is built, so toggling that still needs the rebuild above. |
| `system.listenAddr` / `listenPort`, `webhook.enabled` / `listenAddr` / `listenPort`, `komari.dashboardURL`, `dataPath`, `dbPath` | **Applied at startup only.** Saving them changes the file and the in-memory configuration but not the running listener, database or Komari client — restart to apply. `/api/settings/set` reports them back in `restartRequired`. |
## API
Success responses follow the envelope `{"success": true, "message": "...", "data": {...}}`, with the payload nested under a single `data` key. Error responses use `{"success": false, "message": "..."}`. `message` may be omitted on success when there is nothing to report.
@@ -371,12 +317,12 @@ Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLog
|---|---|---|---|
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `data: {token, userID, username, level, registerDate}`. |
| `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `data: {token, userID, username, level}`. |
| `/api/server/list` | GET | bot / admin | List all monitored servers as rendered text (`data.list`, same output as the bot's `/list`). |
| `/api/server/getInfo` | GET | admin | Static server info (mirrors the bot's `/info`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, info}}` — one entry per requested server. `404` for an unknown single uuid. |
| `/api/server/getStatus` | GET | admin | Live server status (mirrors the bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. |
| `/api/server/list` | GET | bot / admin | List all monitored servers. |
| `/api/server/getInfo` | GET | admin | Static server info (mirrors the Bot's `/info`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, info}}` — one entry per requested server. `404` for an unknown single uuid. |
| `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. |
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. |
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` — returns `data: {config}`, the selected config file's content (same layout as the JSON file). |
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` — deep-merges the body into the selected file, persists it, and reloads it in memory. Only the given keys change; arrays replace, and a `null` deletes a key. Returns `data: {type, restartRequired}`, where `restartRequired` lists the keys the update changed that are only read at startup (see [What applies without a restart](#what-applies-without-a-restart)) — the write succeeds regardless, this only says which edits are not live yet. Always an array, empty when everything took effect. |
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). |
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. |
| `/api/webhook/add` | POST | admin | Add an incoming webhook endpoint. Body `{name, enabled?, token?, notifyPipes?}` — only the fields given are stored, the rest start at their defaults. `409` when the name is already configured. |
| `/api/webhook/modify` | POST | admin | Change an existing endpoint. Body `{name, ...}` — the fields given are the fields that change (same partial-update rule as `/api/settings/set`, but scoped to one endpoint). `404` for an unknown name, `400` when no other field is given. |
| `/api/webhook/delete` | POST | admin | Remove an endpoint. Body `{name}`. `404` for an unknown name. |
@@ -386,10 +332,10 @@ Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLog
Middleware applied to the whole server:
- **Rate limit** — token bucket, 100 requests/minute per client IP (`429` when exceeded).
- **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.
- **WebSocket auth** — the log endpoint is wrapped in an admin-only auth middleware at route level, so an unauthorized client is rejected before the upgrade.
- **WebSocket auth** — `utils.WebSocketAuthMiddleware` is attached to `/api/system/getLogs` (route-level, not global): it authenticates the upgrade request and requires an `admin` token before the connection is handed to the log handler.
### Incoming webhook API
@@ -401,15 +347,15 @@ The console's **WebHooks** page manages the listener settings and the endpoints.
|---|---|---|---|
| `/api/webhook/post/<name>` | POST | Endpoint token | Relay an alert to the channels the endpoint lists in `notifyPipes`. Body `{token, subject, content}`. Returns `data: {endpoint, channels}`. |
Every entry under `webhook.endpoints` is one endpoint, addressed by its key as the last path segment: the key `example` is served at `POST /api/webhook/post/example`. The `post/` segment keeps the endpoints' own namespace separate from the management routes (`/api/webhook/add` and friends), which live on the admin listener. Endpoints are managed over the admin API, which writes the same `webhook.endpoints` section of `config.json`; a newly added endpoint accepts requests as soon as the configuration is reloaded, without a restart. An endpoint holds:
Every entry under `webhook.endpoints` is one endpoint, addressed by its key as the last path segment: the key `example` is served at `POST /api/webhook/post/example`. The `post/` segment keeps the endpoints' own namespace separate from the management routes (`/api/webhook/add` and friends), which live on the admin listener. Endpoints are managed over the admin API (`/api/webhook/add`, `modify`, `delete` and `list` — see [Endpoints](#endpoints)), which writes the same `webhook.endpoints` section of `config.json`; a newly added endpoint accepts requests as soon as the configuration is reloaded, without a restart. An endpoint holds:
| Field | Meaning |
|---|---|
| `enabled` | Whether the endpoint accepts requests. A disabled endpoint answers `403`. |
| `token` | Shared secret the caller sends as the `token` body field; compared in constant time. An endpoint with an empty token answers `500` instead of accepting requests from anyone. |
| `notifyPipes` | The channels the alert is delivered to, named as in `controllerMethod`: `qq_onebot`, `telegram`, `email`, `ntfy`, `webhook`. A channel that is unknown or disabled is skipped and reported. |
| `notifyPipes` | The channels the alert is delivered to, named as in `controllerMethod`: `qq(napcat)`, `telegram`, `email`, `ntfy`, `webhook`. A channel that is unknown or disabled is skipped and reported. |
The management API accepts exactly these three fields. A request naming any other field, or giving one of them the wrong type (`enabled` must be a boolean, `token` a string, `notifyPipes` an array of strings), is refused with `400` instead of being written to `config.json` — a field the program does not understand must not end up in the file. A `name` must be non-empty and free of `/`, since it becomes the last segment of the endpoint URL. Request bodies are capped at 1 MiB.
The management API accepts exactly these three fields. A request naming any other field, or giving one of them the wrong type (`enabled` must be a boolean, `token` a string, `notifyPipes` an array of strings), is refused with `400` instead of being written to `config.json` — a field the program does not understand must not end up in the file. A `name` must be non-empty and free of `/`, since it becomes the last segment of the endpoint URL.
The alert is rendered per channel as:
@@ -429,7 +375,7 @@ Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`
## Bots
QQ (OneBot 11) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. The QQ controller speaks OneBot 11 over WebSocket (event stream + actions), so it works with NapCat or any compatible implementation; Telegram uses `go-telegram/bot` long polling.
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
@@ -458,15 +404,13 @@ QQ (OneBot 11) and Telegram bots share one command engine and authorization pipe
## Notification channels
QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **status-only** channels — they receive 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.
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.
All five channels can also carry an alert submitted by an external application through the [incoming webhook API](#incoming-webhook-api). A bot channel delivers it to the groups and admins configured for that channel; a status-only channel delivers it to its configured destination (mail recipients, ntfy topic, outgoing webhook URL). Markdown formatting is decided per channel by its `markdown` setting, never by the channel's name.
Status-change notifications pass through the per-node gates in `bot_node_config.json` first (`enableStatusNotify`, `flap_tolerant`), so both chat bots and status-only channels see exactly the same filtered stream.
## Building
Requires Go 1.27.3+ and — to build the web console — Node.js 22+.
Requires Go 1.25+ and — to build the web console — Node.js 22+.
Helper scripts in the repo root build the Vue console first, then compile the backend with it embedded. They also bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
@@ -513,28 +457,25 @@ CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest
## Running
All three config files are read from the working directory; there are no path flags:
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
```bash
# Development (go run, so the config files must be in the CWD)
./run.sh # Linux / macOS
run.bat # Windows
# Development (uses go run, so config files must be in the CWD)
run.bat
# Or build first, then run the binary
./nukumizu-linux-amd64
# Or build first, then run a binary
./nukumizu-linux-amd64 -config config.json -bot-user-config bot_user_config.json
```
`run.sh` / `run.bat` only run the Go backend — they do not build the console, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or the build fails.
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. The incoming webhook listener starts separately when `webhook.enabled` is `true`; a failure to bind it is logged without stopping the rest of the program.
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.
### Frontend development
While working on the frontend, run the two sides separately:
`run.bat` only runs the Go backend — it does not build the console, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or `go run` fails to compile. While working on the frontend, run the two sides separately:
```bash
# Terminal 1 — backend (API + WebSocket) on :8080
./run.sh
run.bat
# Terminal 2 — Vite dev server with hot reload on :5173
cd frontend
@@ -550,18 +491,6 @@ NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
For a production build the Go server serves the embedded console itself, on the normal listen address — see [Building](#building). Vite is not involved at runtime, so `npm run build` alone does not change what a running binary serves: rebuild the binary to pick up frontend changes.
## Testing
```bash
go test ./...
```
The suite runs against the config, handler, node-tracker, controller and pipe packages. It needs no Komari instance or network access.
## Changelog
Release notes and the commits behind each version live in [changeLogs.md](changeLogs.md).
## License
See [LICENSE](LICENSE).
-567
View File
@@ -1,567 +0,0 @@
# Nukumizu 后端
[English](README.md) | 简体中文
面向 [Komari](https://www.komari.wiki) 的远程服务器监控与命令执行子系统。
Nukumizu 会连接 Komari 面板,在内存中维护所有被监控服务器的状态,并把告警推送到多个通知渠道。同时它还在 QQ(通过 [NapCat](https://napneko.github.io/) 或其他 OneBot 11 实现)和 Telegram 上运行交互式机器人,让你可以直接在聊天里控制服务器。前端控制台(Vue 3)编译后内嵌进二进制,浏览器里也能完成同样的监控与配置。
## 功能
- **Komari 集成** — 登录 Komari 面板,启动时及之后每 5 分钟刷新节点列表,并通过 Komari WebSocket 每 5 秒拉取一次各节点最新状态。
- **上游请求重试** — 登录与节点获取在遇到传输层错误、`429` 或 `5xx` 时最多重试 6 次,采用线性退避(0.5s × 第几次)。凭据错误、`4xx` 响应和 JSON-RPC 错误会立即失败,不会反复冲击面板。
- **状态跟踪** — 线程安全的节点跟踪器保存每个服务器的最新报告与静态信息,并检测 `Online` / `Offline` 状态切换。启动后的第一份快照被视为基线,因此重启不会产生一堆误报的“离线”告警。
- **单节点通知控制** — `bot_node_config.json` 可以单独关闭某个节点的状态推送,也可以把 `Offline` 通知暂缓一段可配置的时间:节点在窗口内恢复时,这条告警完全不会发出(抖动抑制)。
- **远程命令执行** — 通过 Komari 任务接口下发命令,并轮询结果(每秒一次,最长 60 秒超时)。
- **交互式机器人** — QQ(OneBot 11,例如 NapCat)与 Telegram 机器人,支持 `/list`、`/status`、`/info`、`/run`、`/shutdown`、`/reboot` 等命令,并带有管理员 / 可信群组的权限模型。
- **通知渠道** — 状态变更会推送到所有已启用的渠道:QQ、Telegram、邮件(SMTP)、[ntfy](https://ntfy.sh) 和 Webhook。
- **入站 Webhook API** — 外部应用可以通过 `POST /api/webhook/post/<name>` 推送自己的告警,Nukumizu 会转发给该端点配置的渠道。每个端点有独立的 token 和目标渠道,且该 API 运行在**独立监听端口**上,可以只对外开放它而不暴露管理 API。
- **网络代理** — 可配置全局代理地址,并在每个渠道上通过 `networkUseProxy` 单独开启;支持 HTTP、WebSocket,甚至 SMTP(通过 HTTP CONNECT 隧道)。
- **可自定义的消息模板** — 所有机器人 / 通知消息都由 `config.json` 中的模板渲染,Markdown 格式按渠道单独开关。
- **热重载** — 保存设置后绝大部分配置立即生效;少数只在启动时读取的键会通过 `restartRequired` 返回给调用方。
- **存储** — `user.db` 与 `log.db` 使用 SQLite(纯 Go 驱动);采用回滚日志而非 WAL,避免在网络共享盘上损坏数据库。
- **管理 API** — 基于 token 的 REST API,以及仅管理员可用的实时日志 WebSocket。
- **Web 控制台** — Vue 3 管理界面,可浏览节点及其单节点选项、编辑设置、管理机器人信任与 Webhook 端点、实时查看日志。构建产物内嵌在二进制中,单个可执行文件即可同时提供 API 与控制台。
- **测试** — 各 Go 包带有单元测试(`go test ./...`),覆盖重试、抖动抑制和设置合并等路径。
## 工作原理
1. 启动时 Nukumizu 加载 `config.json`、`bot_user_config.json` 和(可选的)`bot_node_config.json`,然后登录 Komari 面板。登录遇到传输错误 / `429` / `5xx` 时最多重试 6 次;仍然失败则进程退出。
2. 获取节点列表(名称 → UUID、静态信息)并存入内存,随后连接 `/api/rpc2` 的 WebSocket,每 5 秒请求一次最新状态。连接断开后按指数退避重连(1s、2s、4s……最大 30s);连续 5 次失败会记录错误日志,并继续每 30 秒重试。
3. 节点跟踪器会等待覆盖所有已知节点的首份快照后再启用通知(另有两分钟兜底),并且始终把第一份快照当作启动基线 —— 不会因为启动时的状态而产生告警。
4. 真实的在线状态切换会通过 `SERVER_STATUS_CHANGED` 模板渲染,并发送给所有已启用的渠道。在此之前会先经过 `bot_node_config.json` 的单节点开关:`enableStatusNotify` 可以直接丢弃这次变更,`flap_tolerant` 会把 `Offline` 暂缓 N 秒 —— 节点在窗口内恢复时,`Offline` 和随后的 `Online` 都不会发送。
5. 首次状态刷新完成后,QQ / Telegram 还会收到启动欢迎消息和初始服务器列表,并遵循每个成员的 `event_bot_started` 偏好。
6. 每 12 小时重新登录一次 Komari;节点列表每 5 分钟刷新一次。
## 项目结构
```
nukumizu-backend/
├── main.go # 入口、启动流程、后台任务、优雅退出
├── router.go # HTTP 路由注册(主监听 + webhook 监听)
├── config/
│ ├── config.go # 加载配置文件、填充默认值、单节点选项读取
│ ├── variables.go # 配置结构体 + 原子配置单例
│ ├── settings.go # 设置文件读取/合并/写入、启动项检测
│ ├── webhook.go # 入站 Webhook 端点增删改辅助函数
│ ├── reload.go # 重载钩子(日志级别、渠道重建)
│ └── *_test.go # 设置合并、并发与抖动抑制测试
├── global/
│ └── variables.go # 构建元信息(名称/版本/开发者)+ 配置路径
├── handler/
│ ├── user.go # /api/user/login、/api/user/register
│ ├── server.go # /api/server/list、getInfo、getStatus、exec
│ ├── settings.go # /api/settings/get、set
│ ├── webhook.go # /api/webhook/post/{name}(入站 Webhook API)
│ ├── webhook_endpoints.go # /api/webhook/add、modify、delete、list
│ └── health.go # /health
├── database/
│ └── user.go # user.db(SQLite)用户存储
├── utils/
│ ├── auth.go # Token 管理、鉴权中间件、JSON 响应
│ └── middleware.go # 限流、CORS、安全响应头
├── postLog/ # 日志子系统
│ ├── postLog.go # 分级日志(标准输出 + 广播)
│ ├── database.go # log.db(SQLite,每次运行一张表)
│ ├── logBroadcaster.go # 向 WebSocket 客户端分发
│ └── logSocketHandler.go # /api/system/getLogs WebSocket(仅管理员)
├── web/
│ ├── embed.go # 将构建好的控制台(web/dist)嵌入二进制
│ ├── dist/ # Vite 构建输出 —— 生成物,已 gitignore
│ └── handler.go # 静态文件服务 + SPA 回退
├── internal/
│ ├── komari/
│ │ ├── client.go # Komari HTTP/JSON-RPC 客户端(登录、节点、任务执行/轮询)
│ │ └── ws.go # Komari 状态 WebSocket(轮询 + 重连)
│ ├── node/
│ │ └── tracker.go # 线程安全节点状态、基线门控、状态切换
│ ├── netproxy/
│ │ └── netproxy.go # 各渠道统一网络代理
│ ├── template/
│ │ └── template.go # 消息模板渲染({{ 变量 }})
│ └── controller/
│ ├── controller.go # Manager、接口、告警路由、抖动抑制
│ ├── trigger.go # 命令解析、鉴权、路由
│ ├── processor.go # 命令处理
│ ├── utils.go
│ └── pipes/
│ ├── email.go # 邮件通知渠道
│ ├── ntfy.go # ntfy 通知渠道
│ ├── webhook.go # 出站 Webhook 通知渠道
│ ├── qq_onebot/
│ │ ├── qq.go # QQ 渠道(OneBot 11,例如 NapCat)
│ │ └── onebot.go # OneBot 11 WebSocket 客户端(事件 + API)
│ └── telegram/
│ ├── telegram.go # Telegram 机器人(go-telegram/bot,长轮询)
│ └── send.go # 消息发送 / 拆分(Telegram Markdown)
└── frontend/ # Vue 3 管理控制台(Vite)
├── index.html
├── vite.config.js # 开发服务器;把 /api 代理到后端
└── src/
├── main.js # 启动:主题 + 运行时开关
├── App.vue # 根组件 + toast 容器
├── api/index.js # REST 接口封装
├── router/index.js # 路由与登录守卫
├── styles/ # theme.css + ui.css
├── utils/ # http/auth/theme/toast/format/runtime 辅助
├── components/ # Modal、Toggle、编辑器、ConfigSection、顶栏、侧栏
└── views/ # Login、Layout、Overview、Trusted、Settings、WebHooks、Logs
```
## 环境要求
- Go **1.27.3** 或更高版本(见 `go.mod`)
- [Node.js](https://nodejs.org) **22** 或更高版本 —— 仅在构建 Web 控制台时需要,预编译的二进制不需要
- 一台可从本机访问的 [Komari](https://www.komari.wiki) 面板
- QQ:一个提供正向 WebSocket 端点的 OneBot 11 实现,例如 [NapCat](https://napneko.github.io/)(`/api` 与 `/event`)
- Telegram:来自 [@BotFather](https://t.me/BotFather) 的 bot token
主要依赖:`github.com/go-telegram/bot`、`github.com/gorilla/websocket`、`gopkg.in/mail.v2`、`modernc.org/sqlite`。
## 配置
程序从工作目录读取三个 JSON 文件。**没有命令行参数**:文件名固定为 `config.json`、`bot_user_config.json`、`bot_node_config.json`,因此请在存放这些文件的目录下启动程序。
| 文件 | 是否必需 | 用途 |
|---|---|---|
| `config.json` | 是 | 核心配置:系统、调试开关、Komari、Webhook 监听、渠道、消息模板、存储路径 |
| `bot_user_config.json` | 是 | 各机器人的管理员 / 可信群组及其通知偏好 |
| `bot_node_config.json` | 否 | 单节点选项(通知开关、抖动抑制窗口)。首次获取节点时自动创建 |
> 三个文件都在 `.gitignore` 中,因为它们包含凭据(Komari 密码、bot token、代理认证)。请参考下面的示例,切勿提交真实密钥。`bot_node_config.json` 本身不含敏感信息,但也一并被忽略。
### `config.json`
```json
{
"system": {
"debugMode": true,
"listenAddr": "0.0.0.0",
"listenPort": "8080",
"networkProxy": "http://127.0.0.1:7890"
},
"debug": {
"showOneBotMsg": false,
"showOneBotAction": false,
"showTelegramMsg": false,
"showTriggerCmdEcho": true,
"showKomariTaskEcho": false,
"oneBotIgnoreSelfMsg": false,
"showFlapTolerantEcho": false
},
"komari": {
"dashboardURL": "https://status.example.com",
"account": {
"username": "admin",
"password": "CHANGE_ME"
}
},
"webhook": {
"enabled": false,
"listenAddr": "0.0.0.0",
"listenPort": "8081",
"endpoints": {
"example": {
"enabled": true,
"token": "CHANGE_ME",
"notifyPipes": ["qq_onebot", "telegram", "email", "ntfy"]
}
}
},
"controllerMethod": {
"qq_onebot": {
"enabled": false,
"markdown": false,
"networkUseProxy": false,
"oneBotAddr": "127.0.0.1",
"oneBotPort": "3000",
"oneBotToken": "",
"botQQID": 0,
"listenMethod": "global"
},
"telegram": {
"enabled": false,
"markdown": true,
"networkUseProxy": false,
"botToken": "",
"listenMethod": "global"
},
"email": {
"enabled": false,
"markdown": false,
"networkUseProxy": false,
"smtpHost": "",
"smtpPort": 587,
"username": "",
"password": "",
"from": "",
"to": [],
"useTLS": true
},
"ntfy": {
"enabled": false,
"markdown": false,
"networkUseProxy": false,
"server": "https://ntfy.sh",
"topic": "",
"token": "",
"priority": "default"
},
"webhook": {
"enabled": false,
"markdown": false,
"networkUseProxy": false,
"url": "",
"method": "POST",
"headers": {},
"template": ""
}
},
"controllerMessage": {
"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 }}",
"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.",
"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"
}
```
字段说明:
- `controllerMethod` 以渠道名为键:`qq_onebot`、`telegram`、`email`、`ntfy`、`webhook`。同样的名称也用于 `notifyPipes` 和 `bot_user_config.json`。QQ 渠道使用 OneBot 11 协议,`oneBotAddr` / `oneBotPort` / `oneBotToken` 指向 OneBot 实现(NapCat 默认在 `127.0.0.1:3000`)。
- `system.networkProxy` 是**全局**代理地址。只有当渠道自身的 `networkUseProxy` 为 `true` 时才会使用它,作用于 Telegram HTTP 轮询、QQ OneBot WebSocket、ntfy 与 Webhook 请求,以及邮件 SMTP(通过 HTTP CONNECT 隧道)。
- `webhook` 配置的是**入站** Webhook API(见 [入站 Webhook API](#入站-webhook-api));`controllerMethod.webhook` 配置的是出站 Webhook 通知渠道。两者互不相关。
- `markdown` 是五个渠道各自独立的开关。为 `false`(默认)时所有渲染值都以纯文本插入;为 `true` 时,需要原样阅读的值(UUID、事件消息、命令、命令结果、告警来源与内容)会被包裹为 Markdown 行内代码 / 代码块。程序不会根据渠道名推断,因此渠道只会得到你明确要求的格式 —— 平台不支持 Markdown 时请关闭它。在 Telegram 上它同时决定 `parse_mode`:关闭 `markdown` 时不发送 `parse_mode`,含 `*` 或 `_` 的文本会原样送达,而不会被 API 以“Markdown 格式错误”拒绝。
- `controllerMethod.qq_onebot.listenMethod` / `telegram.listenMethod` —— 见 [机器人响应模式](#机器人响应模式)。
- `debug` 控制各模块的详细日志,仅在 `system.debugMode` 为 `true` 时生效:
- `showOneBotMsg` / `showOneBotAction` —— 收到的 OneBot 消息 / 发出的 OneBot API 调用。
- `showTelegramMsg` —— 收到的 Telegram 更新。
- `showTriggerCmdEcho` —— 交给触发器解析后的命令。
- `showKomariTaskEcho` —— Komari 任务的创建与轮询。
- `oneBotIgnoreSelfMsg` —— 忽略 OneBot 回显的机器人自身消息。
- `showFlapTolerantEcho` —— 抖动抑制的决策(暂缓 / 释放 / 补发)。
- `email.useTLS` 仅为配置兼容而保留。
- `dbPath` 默认为 `./db`,`user.db` 与 `log.db` 会创建在其中。`dataPath` 默认为 `./data`,目前尚未使用,预留给后续的文件存储。
- 缺失的键会使用内置默认值(地址 `0.0.0.0`、端口 `8080`、Webhook API `0.0.0.0:8081`、无 Webhook 端点、OneBot `127.0.0.1:3000`、ntfy 服务器 `https://ntfy.sh`、Webhook 方法 `POST` 等)。消息模板也有内置默认值。`markdown` 默认为 `false`,需要 Telegram 格式时请像示例那样显式开启。
### `bot_user_config.json`
管理员与可信群组按**机器人渠道**分别定义,并把成员 ID 映射到该成员的通知偏好:
```json
{
"qq_onebot": {
"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
}
}
}
}
```
- **QQ**:成员 ID 为 QQ 号;群 ID 为数字群号。
- **Telegram**:成员 ID 可以是 `@username`(该用户给机器人发过消息后会解析为数字用户 ID)或数字用户 ID;群 ID 为数字聊天 ID(超级群为负数)。
- 每个成员的选项:
- `event_status_notify` —— 接收服务器 `Online`/`Offline` 推送。
- `event_bot_started` —— 接收启动时的自动消息(欢迎语 + 初始服务器列表)。
- `event_reply` —— 预留,用于关闭对该成员命令的自动回复。
- 某渠道的管理员也会出现在机器人发送通知的所有可信群组 / 私聊上下文中。
### `bot_node_config.json`
单节点选项,以 Komari 上报的节点 UUID 为键:
```json
{
"0f2a9c1e-1234-4a5b-8c9d-000000000001": {
"enableStatusNotify": false,
"flap_tolerant": 30
},
"7d3b8f20-5678-4c9d-9e0f-000000000002": {}
}
```
| 字段 | 默认值 | 含义 |
|---|---|---|
| `enableStatusNotify` | `true` | 为 `false` 时该节点的状态变更**不会**推送 —— 聊天机器人和通知渠道都不会发送。省略该字段(或整个条目)即为开启。 |
| `flap_tolerant` | `0` | `Offline` 通知的暂缓秒数(允许小数,上限 86400)。节点在窗口内恢复时,`Offline` 与 `Online` 都会被抑制;窗口结束时仍未恢复则补发 `Offline`。为 `0` 或省略时立即通知。 |
该文件由程序自动维护:每次获取节点(`FetchNodes` —— 启动、重连和每 5 分钟刷新)都会把新出现的 UUID 以 `{}` 形式加入,且从不覆盖已有条目,因此手写的选项不会被冲掉。文件缺失或为空也没问题 —— 所有节点使用上述默认值。可通过控制台的 **Nodes** 页面修改这两项,或调用 `POST /api/settings/set?type=bot_node_config`。
### 消息模板
`controllerMessage` 中的模板会在发送前渲染。可用变量如下(`markdown: true` 的渠道还会对需要原样阅读的值加 Markdown 包裹,见上文):
| 变量 | 含义 |
|---|---|
| `{{ time }}` | 服务器当前时间 |
| `{{ serverName }}` | 目标服务器名称 |
| `{{ serverUUID }}` | 目标服务器 UUID |
| `{{ upStatus }}` | `Online` / `Offline` |
| `{{ event }}` | 状态变更事件(`Online` / `Offline`) |
| `{{ message }}` | 状态事件附带的消息 |
| `{{ command }}` | 被执行的命令 |
| `{{ result }}` | 命令执行结果 |
| `{{ list.onlineServers }}` | 格式化后的在线服务器列表(`- 名称 (uuid)`) |
| `{{ list.offlineServers }}` | 格式化后的离线服务器列表 |
| `{{ softwareVersion }}`、`{{ softwareBuildVer }}`、`{{ softwareCommitHash }}`、`{{ softwareBuildType }}`、`{{ softwareBuildTime }}`、`{{ softwareDeveloper }}`、`{{ softwareDescription }}` | 构建元信息(commit hash 与构建时间在编译期注入) |
### 无需重启即可生效的配置
保存设置后,大部分配置立即生效:
| 配置 | 生效方式 |
|---|---|
| `controllerMethod`(全部五个渠道) | 所有渠道会被**重建**:停止正在运行的控制器,再按新设置构建一套。持有连接的渠道会重新连接 —— Telegram 重新执行 `getMe` 握手,OneBot 重新建立 WebSocket —— 因此切换期间的推送会丢失。仅当该配置节确实发生变化时才重建;只保存消息模板不会影响渠道。 |
| `controllerMessage`、`debug`、`bot_user_config.json`、`bot_node_config.json`、`webhook.endpoints` | 使用时即时读取,不会重启任何东西。若在 `Offline` 暂缓期间修改通知开关或抖动窗口,补发时会重新读取。 |
| `system.debugMode` | 同时影响行为与日志过滤。 |
| `system.networkProxy` | 每次请求、每次拨号都会重新读取,因此新地址对已在运行的渠道也生效。只有渠道级的 `networkUseProxy` 在构建渠道时固定,切换它仍需上面的重建。 |
| `system.listenAddr` / `listenPort`、`webhook.enabled` / `listenAddr` / `listenPort`、`komari.dashboardURL`、`dataPath`、`dbPath` | **仅在启动时生效。** 保存会写入文件并更新内存配置,但不会改变正在运行的监听、数据库或 Komari 客户端 —— 需要重启。`/api/settings/set` 会通过 `restartRequired` 返回这些键。 |
## API
成功响应统一为 `{"success": true, "message": "...", "data": {...}}`,负载放在单个 `data` 键下。错误响应为 `{"success": false, "message": "..."}`。成功且无额外信息时 `message` 可以省略。
### 鉴权
请求通过 HTTP 头鉴权:
| 请求头 | 含义 |
|---|---|
| `X-Token` | 登录/注册返回的 token。仅保存在内存中(重启后失效)。 |
| `X-Timestamp` | Unix 时间戳(秒);与服务器时间相差超过 ±30 分钟会被拒绝。**当 `system.debugMode` 为 `true` 时完全跳过检查。** |
空闲超过 1 小时的 token 会失效(每 10 分钟清理一次);任何已鉴权的调用都会刷新计时。权限级别:`None`、`bot`、`admin`。要求 `bot` 的接口接受 `bot` 与 `admin` 两种 token。目前注册/登录一律签发 `admin` 级别 token。
浏览器的 WebSocket 握手无法携带自定义请求头,因此 `/api/system/getLogs` 也接受 `?token=` 与 `?timestamp=` 查询参数(两者都存在时请求头优先)。该接口仅接受 `admin` token;时间戳只在握手时检查一次,已建立的连接不受后续容差窗口影响。查询串可能进入代理与访问日志,带 token 的 WebSocket URL 应视为机密。
### 接口列表
| 接口 | 方法 | 权限 | 说明 |
|---|---|---|---|
| `/api/user/login` | POST | None | 登录。请求体 `{username, password}`。返回 `data: {token, userID, username, level, registerDate}`。 |
| `/api/user/register` | POST | None | 注册首个用户。请求体 `{username, password}`。仅在尚无用户时允许,否则 `403`。返回 `data: {token, userID, username, level}`。 |
| `/api/server/list` | GET | bot / admin | 以渲染后的文本列出所有被监控服务器(`data.list`,与机器人 `/list` 输出一致)。 |
| `/api/server/getInfo` | GET | admin | 服务器静态信息(对应机器人 `/info`)。查询 `?uuid=<uuid>`(或 `all`)。返回 `data: {<uuid>: {uuid, name, info}}`,每个请求的服务器一项。单个未知 uuid 返回 `404`。 |
| `/api/server/getStatus` | GET | admin | 服务器实时状态(对应机器人 `/status`)。查询 `?uuid=<uuid>`(或 `all`)。返回 `data: {<uuid>: {uuid, name, online, report}}`;节点尚未上报时 `report` 为 `null`。单个未知 uuid 返回 `404`。 |
| `/api/server/exec` | POST | bot / admin | 执行命令。请求体 `{uuid: [<uuid>...], command}`。下发 Komari 任务并轮询至完成(或超时)。返回 `data: {taskID, results}`。 |
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` —— 返回 `data: {config}`,即对应配置文件的内容(布局与文件一致)。 |
| `/api/settings/set` | POST | admin | `?type=<同上>` + 部分更新的 JSON 请求体,例如 `{"system":{"debugMode":true}}` —— 深度合并到对应文件、写盘并在内存中重载。只改动给出的键;数组整体替换,`null` 删除该键。返回 `data: {type, restartRequired}`,其中 `restartRequired` 列出本次改动中只在启动时读取的键(见 [无需重启即可生效的配置](#无需重启即可生效的配置))—— 写入始终成功,这个字段只表示哪些修改尚未生效。始终为数组,全部生效时为空。 |
| `/api/webhook/add` | POST | admin | 新增入站 Webhook 端点。请求体 `{name, enabled?, token?, notifyPipes?}` —— 只保存给出的字段,其余使用默认值。名称已存在时返回 `409`。 |
| `/api/webhook/modify` | POST | admin | 修改已有端点。请求体 `{name, ...}` —— 给出哪些字段就改哪些(规则同 `/api/settings/set`,但限定在单个端点)。名称不存在返回 `404`,未给出其他字段返回 `400`。 |
| `/api/webhook/delete` | POST | admin | 删除端点。请求体 `{name}`。名称不存在返回 `404`。 |
| `/api/webhook/list` | GET | admin | 所有已配置的入站 Webhook 端点,以名称为键,位于 `data.endpoints`。 |
| `/health` | GET | None | 健康检查。返回 `data: {status, database}`。 |
| `/api/system/getLogs` | WebSocket | admin | 推送日志。先发送最近 100 条缓冲,再实时推送 `{level, content, timestamp}`。凭据可通过 `X-Token`/`X-Timestamp` 请求头或 `?token=`/`?timestamp=` 查询参数提供;校验失败会返回 JSON 错误且不升级连接。 |
作用于整个服务的中间件:
- **限流** —— 令牌桶,每个客户端 IP 每分钟 100 次请求(超出返回 `429`)。
- **CORS** —— `Access-Control-Allow-Origin: *`,允许 `Content-Type`、`X-Token`、`X-Timestamp`、`Authorization`。
- **安全响应头** —— `X-XSS-Protection`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy` 以及严格的 CSP。
- **WebSocket 鉴权** —— 日志端点通过路由级中间件要求管理员 token,未授权客户端在升级前即被拒绝。
### 入站 Webhook API
它使用独立监听端口,外部应用可以只访问它而接触不到管理 API。通过 `webhook.enabled` 开启,监听 `webhook.listenAddr:webhook.listenPort`(默认 `0.0.0.0:8081`);这部分配置在启动时生效,而 `webhook.endpoints` 会在配置重载时重新读取。这里只应用限流与 CORS 中间件,不涉及会话 token。
控制台的 **WebHooks** 页面负责监听设置与端点管理。出站 Webhook 通知渠道(`controllerMethod.webhook`)仍位于设置页,与其他通知渠道放在一起,因为它本身就是其中一个渠道。
| 接口 | 方法 | 权限 | 说明 |
|---|---|---|---|
| `/api/webhook/post/<name>` | POST | 端点 token | 把告警转发给该端点 `notifyPipes` 中列出的渠道。请求体 `{token, subject, content}`。返回 `data: {endpoint, channels}`。 |
`webhook.endpoints` 下的每个条目就是一个端点,以键名作为 URL 最后一段:键 `example` 对应 `POST /api/webhook/post/example`。`post/` 这一段把端点命名空间与管理路由(`/api/webhook/add` 等,位于管理监听)区分开。端点通过管理 API 增删改,写入的正是 `config.json` 的 `webhook.endpoints` 部分;新增端点在配置重载后即可接收请求,无需重启。端点包含:
| 字段 | 含义 |
|---|---|
| `enabled` | 是否接受请求。禁用时返回 `403`。 |
| `token` | 调用方在请求体 `token` 字段中提供的共享密钥,使用常量时间比较。未配置 token 的端点返回 `500`,而不是对任何人开放。 |
| `notifyPipes` | 告警投递的渠道,命名与 `controllerMethod` 一致:`qq_onebot`、`telegram`、`email`、`ntfy`、`webhook`。未知或未启用的渠道会被跳过并上报。 |
管理 API 只接受上述三个字段。出现其他字段,或字段类型错误(`enabled` 必须是布尔值、`token` 必须是字符串、`notifyPipes` 必须是字符串数组)时返回 `400`,不会写入 `config.json` —— 程序不认识的字段不应出现在文件里。`name` 不能为空且不能包含 `/`,因为它会成为端点 URL 的最后一段。请求体上限为 1 MiB。
告警按渠道渲染为:
```
{{ subject }}
- Source: {{ source }}
- Content:
{{ content }}
- Time: {{ time }}
Sent by Nukumizu Alert System
```
`{{ source }}` 是端点名称,接收方据此判断是哪个应用触发的告警。`markdown: true` 的渠道会把 source 包裹为行内代码、content 包裹为代码块;`{{ subject }}` 与 `{{ time }}` 保持纯文本。
状态码:`200` 已投递,`400` 请求体格式错误或 `subject`/`content` 为空,`401` token 错误,`403` 端点已禁用,`404` 端点名称不存在,`405` 非 POST 请求,`500` 端点未配置 token,`502` 没有任何渠道接受该告警。
## 机器人
QQ(OneBot 11)与 Telegram 机器人共用同一套命令引擎与鉴权流程,实现在 `internal/controller/`。QQ 渠道通过 WebSocket 使用 OneBot 11(事件流 + API),因此兼容 NapCat 及同类实现;Telegram 使用 `go-telegram/bot` 长轮询。
### 权限模型
- **可信群组**:*群内*发出的命令只有在该群位于 `trustedGroups` 中时才会得到响应,其他群的消息会被忽略。
- **管理命令**:`/shutdown`、`/reboot` 和 `/run` 还要求*发送者*位于 `admins` 中。
- **私聊**:非管理命令(`/help`、`/list`、`/status`、`/info`、`/getip`)对任何私聊发送者都响应;管理命令仍要求管理员。
### 机器人响应模式
- `global` —— 机器人监听可信群组中的所有消息,无需被 @ 即可响应已知命令;未知的 `/` 命令会被静默忽略。
- `at` —— 机器人只在自己被提及时响应(QQ `@`、Telegram `@botname`)。此模式下未知命令会回复 `Unknown command: /…`。
### 命令
| 命令 | 权限 | 说明 |
|---|---|---|
| `/help` | 所有人 | 显示帮助消息(`BOT_HELP` 模板)。 |
| `/list` | 所有人 | 列出所有服务器及其在线状态(`SERVER_LIST` 模板)。 |
| `/status <uuid>` | 所有人 | 服务器实时报告(CPU、内存、磁盘、网络、运行时长、进程)。 |
| `/info <uuid>` | 所有人 | 服务器静态信息(系统、内核、CPU、内存、交换、磁盘、计费、标签)。 |
| `/getip <uuid>` | 所有人 | 服务器的 IPv4 / IPv6 地址。 |
| `/shutdown <uuid>` | 管理员 | 通过 Komari 任务接口关机。 |
| `/reboot <uuid>` | 管理员 | 通过 Komari 任务接口重启。 |
| `/run <uuid\|all> <command>` | 管理员 | 在单台(`all` 表示全部)服务器上执行命令并回报结果(`SERVER_EXECUTE_RESULT` 模板)。 |
| `/start` | 所有人 | 仅 Telegram —— 发送 `TG_BOT_START` 欢迎消息。 |
## 通知渠道
QQ 与 Telegram 是*交互式*渠道。邮件、ntfy 与 Webhook 是**仅状态**渠道 —— 它们只接收状态变更告警,不能执行命令。启动时,欢迎消息与初始服务器列表只发送给机器人渠道(QQ / Telegram),并遵循每个成员的 `event_bot_started` 偏好。
五个渠道也都能承载外部应用通过[入站 Webhook API](#入站-webhook-api)提交的告警。机器人渠道会发给该渠道配置的群组与管理员;仅状态渠道会发给各自的目的地(收件人、ntfy topic、出站 Webhook URL)。Markdown 格式由各渠道的 `markdown` 设置决定,与渠道名称无关。
状态变更通知会先经过 `bot_node_config.json` 的单节点开关(`enableStatusNotify`、`flap_tolerant`),因此聊天机器人与仅状态渠道看到的是同一份过滤后的数据流。
## 构建
需要 Go 1.27.3+,构建 Web 控制台时还需要 Node.js 22+。
仓库根目录的辅助脚本会先构建 Vue 控制台,再把后端与它一起编译;同时通过 `-ldflags` 把当前 git commit 与构建时间写进二进制。脚本名表示**目标平台**,每个目标都有 Windows(`.bat`)和 Linux/macOS(`.sh`)两种版本:请在当前宿主机上运行对应版本,因为所有脚本都会交叉编译到各自的目标平台。
| 脚本 | 产物 |
|---|---|
| `build-linux-x86_64.sh` / `build-linux-x86_64.bat` | `nukumizu-linux-amd64` |
| `build-win-x86_64.sh` / `build-win-x86_64.bat` | `nukumizu-windows-amd64.exe` |
```bash
# Linux / macOS
./build-linux-x86_64.sh
./build-win-x86_64.sh
# Windows
build-linux-x86_64.bat
build-win-x86_64.bat
```
控制台是**内嵌在二进制中**的。Vite 将其输出到 `web/dist`,`web/embed.go` 通过 `go:embed` 把它编译进去,因此可执行文件可以独立提供完整前端 —— 复制到任何位置,即使旁边没有 `frontend/` 或 `web/dist`,访问 `/` 仍会返回控制台。构建脚本会在 `frontend/node_modules` 缺失时执行 `npm ci`,并在每次运行时执行 `npm run build`,因此构建机需要 Node.js 22+(运行二进制的机器不需要)。
也正因如此,编译后端前必须至少构建过一次控制台:`web/dist` 是生成目录且被 gitignore,在它存在之前 `go build` 会报 `pattern all:dist: no matching files found`。任何 `build-*` 脚本都会替你处理好顺序。
等价的手动构建:
```bash
# 1. 控制台(前端改动后执行一次)
cd frontend && npm ci && npm run build && cd ..
# 2. 后端
# 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` 与 `main.BuildTime` 会出现在日志和 `BOT_STARTED` 消息中。
CI(`.github/workflows/build.yml`)会运行两种版本 —— `.sh` 在 `ubuntu-latest`,`.bat` 在 `windows-latest` —— 并把两个自包含的二进制作为产物上传。
## 运行
三个配置文件都从工作目录读取,没有路径参数:
```bash
# 开发运行(go run,因此配置文件必须在当前目录)
./run.sh # Linux / macOS
run.bat # Windows
# 或先构建,再运行二进制
./nukumizu-linux-amd64
```
`run.sh` / `run.bat` 只运行 Go 后端,不会构建控制台,因此 `web/dist` 必须已存在(执行过一次任意 `build-*` 脚本,或在 `frontend/` 中执行 `npm run build`),否则编译会失败。
启动时程序会登录 Komari、加载节点状态、连接状态 WebSocket,然后启动各已启用渠道以及 `listenAddr:listenPort` 上的 HTTP 服务。按 `Ctrl+C` 优雅退出。当 `webhook.enabled` 为 `true` 时,入站 Webhook 监听会单独启动;绑定失败只会记录日志,不会影响程序其余部分。
### 前端开发
开发前端时,请分别运行前后端:
```bash
# 终端 1 —— 后端(API + WebSocket)监听 :8080
./run.sh
# 终端 2 —— Vite 开发服务器,热更新,监听 :5173
cd frontend
npm install
npm run dev
```
打开 http://localhost:5173。开发服务器会把 `/api`(包括日志 WebSocket)代理到 `http://127.0.0.1:8080`;若后端监听在别处,用 `NUKUMIZU_API` 指向它:
```bash
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
```
生产构建时,Go 服务会在正常监听地址上直接提供内嵌的控制台 —— 见[构建](#构建)。运行时与 Vite 无关,因此单独执行 `npm run build` 不会改变已运行二进制的内容:需要重新构建二进制才能生效。
## 测试
```bash
go test ./...
```
测试覆盖 config、handler、节点跟踪器、控制器与各管道包,不需要 Komari 实例或网络访问。
## 更新日志
每个版本的发布说明及对应 commit 见 [changeLogs.md](changeLogs.md)。
## 许可证
见 [LICENSE](LICENSE)。
+8 -28
View File
@@ -1,43 +1,23 @@
## Ver.0.2.3.8
### Features
- Add retry attemption when login to Komari Dashboard failed ([a7f5fac](https://github.com/MadobiNanami/Nukumizu/commit/a7f5fac1d4a33506d5b2f293ad8f8fef150d4ee9) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a7f5fac1d4a33506d5b2f293ad8f8fef150d4ee9))
- Add retry attemption when fetching nodes from dashboard ([064524e](https://github.com/MadobiNanami/Nukumizu/commit/064524e9912700f35b670dcd09e99199daa9c12c) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/064524e9912700f35b670dcd09e99199daa9c12c))
## Ver.0.2.2.7-8c2159f.pre-release
### Features
- Add flap inhibiton for nodes ([03e74f4](https://github.com/MadobiNanami/Nukumizu/commit/03e74f49c36ba05995651135bd0f472b95bfede6) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/03e74f49c36ba05995651135bd0f472b95bfede6))
- Put all node-related settings into a seperate dialog box ([1a7a4cd](https://github.com/MadobiNanami/Nukumizu/commit/1a7a4cdc073cccec4aa5fb61e139919b0c757dbe) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/1a7a4cdc073cccec4aa5fb61e139919b0c757dbe))
## Ver.0.2.1.6-d06d0bc.pre-release
### Features
- Most of settings now support hot reload when changed
- Network proxy ([457ea42](https://github.com/MadobiNanami/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075))
- Controllers ([2a5a46a](https://github.com/MadobiNanami/Nukumizu/commit/2a5a46a9846c5afe8ac3c66dc42164ddea25c038) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/2a5a46a9846c5afe8ac3c66dc42164ddea25c038))
- Only log node count when it changed ([aa7e7f8](https://github.com/MadobiNanami/Nukumizu/commit/aa7e7f87e17f17ff2f64aa1823a00a291628d4eb) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/aa7e7f87e17f17ff2f64aa1823a00a291628d4eb))
### Bug Fixes
- Resolve the proxy address per request ([457ea42](https://github.com/MadobiNanami/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075))
## Ver.0.2.0.5-661cf08.pre-release
### Features
- Enhance security of websocket log system ([da467c9](https://github.com/MadobiNanami/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9))
- Implement incoming webhook API interface with configurable endpoints ([4853340](https://github.com/MadobiNanami/Nukumizu/commit/48533404fac20c23134e3ec1d3305ebdf4423c80) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/48533404fac20c23134e3ec1d3305ebdf4423c80))
- Implement webhook API settings and configuration in frontend webpage ([6046fb5](https://github.com/MadobiNanami/Nukumizu/commit/6046fb5f882c108b7e94e9537bc15de288bd204b) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6046fb5f882c108b7e94e9537bc15de288bd204b))
- [Enhance security of websocket log system]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9")
- [Implement incoming webhook API interface with configurable endpoints]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/48533404fac20c23134e3ec1d3305ebdf4423c80")
- [Implement webhook API settings and configuration in frontend webpage]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6046fb5f882c108b7e94e9537bc15de288bd204b")
## Ver.0.1.2.4-a6f3107.pre-release
### Bug Fixes
- Integrate frontend build into backend binary ([cc4aebd](https://github.com/MadobiNanami/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d))
- [Integrate frontend build into backend binary]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d")
## Ver.0.1.2.3-1c4ad61.pre-release
### Features
- Add frontend webpage to make everything easy to control ([a90b4f5](https://github.com/MadobiNanami/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612))
- Add scripts for building frontend and backend for Linux and Windows ([b970d66](https://github.com/MadobiNanami/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6))
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
- [Add scripts for building frontend and backend for Linux and Windows]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6")
### Bug Fixes
- Update API response structure to nest payloads under a single "data" key ([bc7eb9d](https://github.com/MadobiNanami/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1))
- [Update API response structure to nest payloads under a single "data" key]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1")
## Ver.0.1.1.2-15f1ee3.pre-release
### Features
- Add per-node notify switch controller ([6a0c2d1](https://github.com/MadobiNanami/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca) · [gitea](https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca))
- [Add per-node notify switch controller]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca")
-164
View File
@@ -1,164 +0,0 @@
package config
import (
"sync"
"testing"
"nukumizu-backend/global"
)
// TestConcurrentReloadAndRead drives every configuration accessor from reader
// goroutines while UpdateSettings and SaveBotNodeConfig replace the in-memory
// configurations underneath them. Run with -race to check that the swap is
// race-free: before the singletons were published through atomic pointers this
// pattern was an unsynchronized read of a variable written by LoadGlobalConfig
// and friends, which the race detector reports.
//
// The readers call the accessors the way production code does — take the value
// and use it immediately, never store it — because that is what keeps a reader
// pinned to one complete version of the configuration.
func TestConcurrentReloadAndRead(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{
"system": {
"debugMode": true,
"listenPort": "8080"
},
"webhook": {
"endpoints": {
"example": { "enabled": true, "token": "t", "notifyPipes": ["ntfy"] }
}
},
"controllerMessage": {
"BOT_STARTED": "hello"
}
}`)
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
"qq(napcat)": {
"admins": { "1": { "event_reply": true } },
"trustedGroups": { "2": { "event_status_notify": true } }
}
}`)
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-1": { "enableStatusNotify": true }
}`)
// Seed every singleton so the readers start from a loaded configuration
// rather than racing the first store.
if _, err := LoadGlobalConfig(global.ConfigPath.Global); err != nil {
t.Fatalf("LoadGlobalConfig: %v", err)
}
if _, err := LoadBotUserConfig(global.ConfigPath.BotUserConfig); err != nil {
t.Fatalf("LoadBotUserConfig: %v", err)
}
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
const readers = 4
const rounds = 40
var readersWg, writersWg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < readers; i++ {
readersWg.Add(1)
go func() {
defer readersWg.Done()
for {
select {
case <-stop:
return
default:
}
if cfg := Current(); cfg != nil {
_ = cfg.System.DebugMode
_ = cfg.System.ListenPort
_ = cfg.ControllerMessage.BotStarted
_ = cfg.Webhook.Endpoints
}
if users := BotUsers(); users != nil {
_ = users.QQ.Admins.IDs()
_ = users.QQ.TrustedGroups.IDs()
}
_ = BotNodes()
_ = IsDebugMode()
_ = NodeStatusNotifyEnabled("node-1")
_ = WebhookEndpoints()
_, _ = GetWebhookEndpoint("example")
}
}()
}
// Writer: reloads the global and bot user configurations, and rewrites the
// node registry through UpdateSettings so its reload runs too.
writersWg.Add(1)
go func() {
defer writersWg.Done()
for i := 0; i < rounds; i++ {
enabled := i%2 == 0
patch := map[string]interface{}{
"system": map[string]interface{}{"debugMode": enabled},
"controllerMessage": map[string]interface{}{
"BOT_STARTED": "hello",
},
}
if err := UpdateSettings(SettingGlobal, patch); err != nil {
t.Errorf("UpdateSettings(global): %v", err)
return
}
if err := UpdateSettings(SettingBotUserConfig, map[string]interface{}{
"qq(napcat)": map[string]interface{}{
"admins": map[string]interface{}{
"1": map[string]interface{}{"event_reply": enabled},
},
},
}); err != nil {
t.Errorf("UpdateSettings(bot_user_config): %v", err)
return
}
if err := UpdateSettings(SettingBotNodeConfig, map[string]interface{}{
"node-2": map[string]interface{}{"enableStatusNotify": enabled},
}); err != nil {
t.Errorf("UpdateSettings(bot_node_config): %v", err)
return
}
}
}()
// Second writer: the node tracker's background save, which shares the same
// read-modify-write lock as the admin edits above.
writersWg.Add(1)
go func() {
defer writersWg.Done()
for i := 0; i < rounds; i++ {
if err := SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, []string{"node-1", "node-2"}); err != nil {
t.Errorf("SaveBotNodeConfig: %v", err)
return
}
}
}()
// Let the writers finish, then release the readers. Waiting on the readers
// first would deadlock: they only return once stop is closed.
writersWg.Wait()
close(stop)
readersWg.Wait()
// The last write must be visible: the accessors are not allowed to serve a
// stale configuration once UpdateSettings has returned.
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"debugMode": true},
}); err != nil {
t.Fatalf("final UpdateSettings(global): %v", err)
}
cfg := Current()
if cfg == nil {
t.Fatal("Current() is nil after a successful reload")
}
if !cfg.System.DebugMode {
t.Error("Current() did not observe the reloaded debugMode")
}
if cfg.System.ListenPort != "8080" {
t.Errorf("reload dropped an untouched sibling: listenPort = %q", cfg.System.ListenPort)
}
}
+21 -43
View File
@@ -5,20 +5,19 @@ import (
"encoding/json"
"fmt"
"os"
"time"
)
// 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 defaults —
// enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
// 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) {
botNodeConfig.Store(&cfg)
C_botNodeConfig = cfg
return nil
}
return fmt.Errorf("failed to read bot node config file: %w", err)
@@ -28,44 +27,25 @@ func LoadBotNodeConfig(configPath string) error {
return fmt.Errorf("failed to parse bot node config file: %w", err)
}
}
botNodeConfig.Store(&cfg)
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. A missing configuration (nil map) yields
// the same default.
// explicitly sets the flag to false.
func NodeStatusNotifyEnabled(uuid string) bool {
opts, ok := BotNodes()[uuid]
if C_botNodeConfig == nil {
return true
}
opts, ok := C_botNodeConfig[uuid]
if !ok || opts.EnableStatusNotify == nil {
return true
}
return *opts.EnableStatusNotify
}
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
// hand-written value cannot overflow time.Duration.
const maxFlapTolerantSeconds = 86400
// NodeFlapTolerant returns how long an Offline notification for uuid should be
// held in order to absorb a quick reconnect, per bot_node_config.json's
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
// the value is zero or negative, or the config has not been loaded — in every
// case the caller falls back to notifying immediately.
func NodeFlapTolerant(uuid string) time.Duration {
opts, ok := BotNodes()[uuid]
if !ok || opts.FlapTolerant <= 0 {
return 0
}
seconds := opts.FlapTolerant
if seconds > maxFlapTolerantSeconds {
seconds = maxFlapTolerantSeconds
}
return time.Duration(seconds * float64(time.Second))
}
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
// and stores it as a global singleton.
func LoadGlobalConfig(configPath string) (*Config, error) {
@@ -87,15 +67,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
cfg.System.ListenPort = "8080"
}
// Apply defaults for QQ_OneBot controller.
if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
// Apply defaults for QQ controller.
if cfg.ControllerMethod.QQ.ListenMethod == "" {
cfg.ControllerMethod.QQ.ListenMethod = "global"
}
if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
if cfg.ControllerMethod.QQ.NapcatAddr == "" {
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1"
}
if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
if cfg.ControllerMethod.QQ.NapcatPort == "" {
cfg.ControllerMethod.QQ.NapcatPort = "3000"
}
// Apply defaults for Telegram controller.
@@ -166,7 +146,7 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
cfg.ControllerMessage.ServerExecuteResult = "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
}
globalConfig.Store(&cfg)
C_globalConfig = &cfg
return &cfg, nil
}
@@ -182,7 +162,7 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse bot user config file: %w", err)
}
botUserConfig.Store(&cfg)
C_botUserConfig = &cfg
return &cfg, nil
}
@@ -190,8 +170,7 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
// 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 per-node option a user set by hand — enableStatusNotify or flap_tolerant
// — is left untouched. UUIDs seen for
// 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
@@ -234,9 +213,8 @@ func SaveBotNodeConfig(configPath string, uuids []string) error {
// IsDebugMode returns whether debug mode is enabled.
func IsDebugMode() bool {
cfg := Current()
if cfg == nil {
if C_globalConfig == nil {
return false
}
return cfg.System.DebugMode
return C_globalConfig.System.DebugMode
}
-140
View File
@@ -1,140 +0,0 @@
package config
import (
"encoding/json"
"testing"
"time"
"nukumizu-backend/global"
)
// TestNodeFlapTolerant covers the flap-inhibition window accessor against the
// per-node options in bot_node_config.json, including the hand-written values
// the comment on BotNodeOptions.FlapTolerant promises to accept.
func TestNodeFlapTolerant(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"seconds": { "flap_tolerant": 30 },
"fractional": { "flap_tolerant": 2.5 },
"zero": { "flap_tolerant": 0 },
"negative": { "flap_tolerant": -5 },
"unset": { "enableStatusNotify": true },
"absurd": { "flap_tolerant": 1e9 },
"throughput": { "flap_tolerant": 8, "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
cases := []struct {
name string
uuid string
want time.Duration
}{
{"whole seconds", "seconds", 30 * time.Second},
{"fractional seconds parse", "fractional", 2500 * time.Millisecond},
{"zero disables", "zero", 0},
{"negative disables", "negative", 0},
{"absent field disables", "unset", 0},
{"unknown node disables", "not-in-the-file", 0},
{"absurd value is clamped", "absurd", maxFlapTolerantSeconds * time.Second},
{"independent of enableStatusNotify", "throughput", 8 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NodeFlapTolerant(tc.uuid); got != tc.want {
t.Errorf("NodeFlapTolerant(%q) = %v, want %v", tc.uuid, got, tc.want)
}
})
}
}
// TestNodeFlapTolerantWithoutConfig pins the nil-safe default: before any
// bot_node_config.json has been loaded — and for a file that is missing or
// empty — no node has a window, so notifications keep their previous
// notify-immediately behavior.
func TestNodeFlapTolerantWithoutConfig(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, ``)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
if got := NodeFlapTolerant("anything"); got != 0 {
t.Errorf("node flap tolerance from an empty file = %v, want 0", got)
}
}
// nodeEntry reads one node's raw object back from the settings file, the way
// GetSettings serves it to the console.
func nodeEntry(t *testing.T, uuid string) map[string]interface{} {
t.Helper()
data, err := GetSettings(SettingBotNodeConfig)
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
var all map[string]map[string]interface{}
if err := json.Unmarshal(data, &all); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return all[uuid]
}
// TestFlapTolerantSettingsRoundTrip drives the payload the console's node card
// actually posts — { "<uuid>": { "flap_tolerant": <seconds> } } — through the
// settings API, so the frontend and the accessor are checked against each other
// and not just against hand-written files.
func TestFlapTolerantSettingsRoundTrip(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": 30},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 30*time.Second {
t.Errorf("NodeFlapTolerant after save = %v, want 30s", got)
}
// The sibling option must survive: the card saves only the field it edits.
entry := nodeEntry(t, "node-a")
if entry["enableStatusNotify"] != false {
t.Errorf("enableStatusNotify = %v, want it preserved as false", entry["enableStatusNotify"])
}
if entry["flap_tolerant"] != float64(30) {
t.Errorf("flap_tolerant on disk = %v, want 30", entry["flap_tolerant"])
}
}
// TestFlapTolerantSettingsRemoval: clearing the console's field sends a JSON
// null, which must remove the key rather than leave an explicit 0 behind.
func TestFlapTolerantSettingsRemoval(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": true, "flap_tolerant": 30 }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": nil},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 0 {
t.Errorf("NodeFlapTolerant after removal = %v, want 0", got)
}
entry := nodeEntry(t, "node-a")
if _, present := entry["flap_tolerant"]; present {
t.Error("flap_tolerant is still in the file after being cleared")
}
if entry["enableStatusNotify"] != true {
t.Errorf("enableStatusNotify = %v, want it preserved as true", entry["enableStatusNotify"])
}
}
-81
View File
@@ -1,81 +0,0 @@
package config
import (
"fmt"
"sync"
"nukumizu-backend/postLog"
)
// reloadHooks are the callbacks run after the global configuration has been
// reloaded, i.e. after every settings update that touches config.json.
//
// They exist so that packages which already depend on config — the controller
// manager, the logger — can react to an update without config importing them,
// which would be an import cycle. Everything a hook needs is passed in.
var (
reloadHooksMu sync.Mutex
reloadHooks []func(*Config)
// reloadRunMu serializes hook execution. A hook mutates process-wide state
// — the logger's debug flag, the controller registry — so two overlapping
// settings updates must not run them at the same time, or both would decide
// to rebuild the controllers from their own view of what was applied.
reloadRunMu sync.Mutex
)
// OnReload registers a hook to run after every reload of the global
// configuration, receiving the configuration now in effect. Hooks run in
// registration order.
//
// Register once, at startup, before the first settings update can arrive: a
// hook registered later has already missed the updates that came before it, and
// the configuration it would have seen is not replayed.
//
// A hook runs on the goroutine serving /api/settings/set, so it must not block
// for long. It may be called concurrently by two overlapping updates.
func OnReload(hook func(*Config)) {
reloadHooksMu.Lock()
defer reloadHooksMu.Unlock()
reloadHooks = append(reloadHooks, hook)
}
// notifyReload runs every registered hook with cfg. A nil cfg is the signal
// that the update touched one of the other settings files, which have no
// hook-visible reload, and it is ignored.
//
// A panicking hook is logged and skipped rather than allowed to unwind through
// UpdateSettings: by the time hooks run the new configuration is already on
// disk and published in memory, so reporting the write as failed would be a
// lie, and the hooks registered after the broken one must still run.
func notifyReload(cfg *Config) {
if cfg == nil {
return
}
// Copy under the registry lock, then release it before running anything: a
// hook is free to register another hook without deadlocking.
reloadHooksMu.Lock()
hooks := make([]func(*Config), len(reloadHooks))
copy(hooks, reloadHooks)
reloadHooksMu.Unlock()
reloadRunMu.Lock()
defer reloadRunMu.Unlock()
for _, hook := range hooks {
runReloadHook(hook, cfg)
}
}
// runReloadHook runs one hook, isolating a panic to that hook. Recovering in a
// separate function rather than inline is deliberate: a deferred recover placed
// in the loop body would not run until notifyReload itself returned, which
// would abandon the remaining hooks.
func runReloadHook(hook func(*Config), cfg *Config) {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Configuration reload hook panicked: %v", r))
}
}()
hook(cfg)
}
-173
View File
@@ -1,173 +0,0 @@
package config
import (
"reflect"
"testing"
"nukumizu-backend/global"
)
// swapReloadHooks replaces the registered reload hooks for the duration of a
// test and restores the previous set afterwards, so the package-level registry
// cannot leak into another test.
func swapReloadHooks(t *testing.T, hooks ...func(*Config)) {
t.Helper()
reloadHooksMu.Lock()
original := reloadHooks
reloadHooks = hooks
reloadHooksMu.Unlock()
t.Cleanup(func() {
reloadHooksMu.Lock()
reloadHooks = original
reloadHooksMu.Unlock()
})
}
func TestReloadHookRunsOnlyForGlobalSettings(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{}`)
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{}`)
var seen []bool
swapReloadHooks(t, func(cfg *Config) {
seen = append(seen, cfg.System.DebugMode)
})
// A config.json update runs the hook, with the values just written.
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"debugMode": true},
}); err != nil {
t.Fatalf("UpdateSettings(global): %v", err)
}
if len(seen) != 1 {
t.Fatalf("hook ran %d times for a config.json update, want 1", len(seen))
}
if !seen[0] {
t.Error("hook received a configuration without the updated debugMode")
}
// The other two files reload a singleton that callers read at the point of
// use, so they have nothing to notify.
for _, settingsType := range []string{SettingBotUserConfig, SettingBotNodeConfig} {
if err := UpdateSettings(settingsType, map[string]interface{}{
"unused": map[string]interface{}{"event_reply": true},
}); err != nil {
t.Fatalf("UpdateSettings(%s): %v", settingsType, err)
}
}
if len(seen) != 1 {
t.Errorf("hook ran %d times after updates to the other settings files, want 1", len(seen))
}
}
func TestReloadHookIsolatesPanic(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
reached := false
swapReloadHooks(t,
func(*Config) { panic("hook under test") },
func(*Config) { reached = true },
)
// The configuration is already on disk and published by the time hooks run,
// so a broken hook must not turn a successful write into a failed request.
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"debugMode": true},
}); err != nil {
t.Fatalf("a panicking hook must not fail the settings write: %v", err)
}
if !reached {
t.Error("a panicking hook stopped the hooks registered after it")
}
// The write itself must still have landed.
cfg := Current()
if cfg == nil || !cfg.System.DebugMode {
t.Error("the settings write did not take effect")
}
}
// TestUnrelatedUpdateLeavesControllerMethodAlone guards the trigger for a
// controller rebuild. Whether to rebuild is decided by comparing the whole
// controllerMethod section with the one the running controllers were built
// from, so reloading the file has to reproduce that section byte for byte. A
// default applied inconsistently — a nil recipient slice turned into an empty
// one on the second load, say — would make every settings edit look like a
// controller change and tear down every channel on each save.
func TestUnrelatedUpdateLeavesControllerMethodAlone(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{
"controllerMethod": {
"qq(napcat)": { "enabled": false },
"email": { "enabled": false, "to": [] },
"webhook": { "enabled": false, "headers": {} }
}
}`)
first, err := LoadGlobalConfig(global.ConfigPath.Global)
if err != nil {
t.Fatalf("LoadGlobalConfig: %v", err)
}
before := first.ControllerMethod
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"controllerMessage": map[string]interface{}{"BOT_STARTED": "hello"},
}); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
after := Current().ControllerMethod
if !reflect.DeepEqual(before, after) {
t.Errorf("an unrelated update changed the controllerMethod section:\nbefore: %+v\nafter: %+v", before, after)
}
}
// TestReloadHookSeesEveryWriterPath pins the invariant that each writer of
// config.json notifies, not just /api/settings/set: the webhook endpoint
// helpers go through the same channel.
func TestReloadHookSeesEveryWriterPath(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{
"webhook": { "endpoints": {} }
}`)
var seen int
swapReloadHooks(t, func(*Config) { seen++ })
if err := AddWebhookEndpoint("example", map[string]interface{}{
"enabled": true,
"token": "secret",
"notifyPipes": []interface{}{"ntfy"},
}); err != nil {
t.Fatalf("AddWebhookEndpoint: %v", err)
}
if seen != 1 {
t.Errorf("hook ran %d times after adding an endpoint, want 1", seen)
}
if err := ModifyWebhookEndpoint("example", map[string]interface{}{
"enabled": false,
}); err != nil {
t.Fatalf("ModifyWebhookEndpoint: %v", err)
}
if seen != 2 {
t.Errorf("hook ran %d times after modifying an endpoint, want 2", seen)
}
if err := DeleteWebhookEndpoint("example"); err != nil {
t.Fatalf("DeleteWebhookEndpoint: %v", err)
}
if seen != 3 {
t.Errorf("hook ran %d times after deleting an endpoint, want 3", seen)
}
// A rejected write changes nothing, so it must not notify either.
if err := DeleteWebhookEndpoint("ghost"); err == nil {
t.Error("deleting an unknown endpoint should fail")
}
if seen != 3 {
t.Errorf("hook ran for a rejected write (%d notifications, want 3)", seen)
}
}
+16 -127
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"strings"
"sync"
"nukumizu-backend/global"
@@ -24,89 +23,6 @@ const (
// accepted constants above.
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
// startupOnlySettings are the config.json keys that are read once before the
// program starts serving and never again: the listener addresses, the paths the
// databases are opened from, and the Komari dashboard its client is built
// against. Editing one writes the file and replaces the in-memory
// configuration, but the running program keeps the old value, so an update that
// touches one is reported back to the caller instead of being silently
// accepted.
//
// Keep this in step with main: these are exactly the settings main reads before
// the HTTP server comes up. Everything else — controllerMethod, networkProxy,
// the message templates, the debug switches — is picked up at runtime.
var startupOnlySettings = []string{
"system.listenAddr",
"system.listenPort",
"webhook.enabled",
"webhook.listenAddr",
"webhook.listenPort",
"komari.dashboardURL",
"dataPath",
"dbPath",
}
// IsRestartRequiredKeys lists the settings in patch that only take effect at
// startup, as dot-separated paths, in the order startupOnlySettings declares
// them. Only config.json carries such settings; an update to one of the other
// files always reports nothing.
//
// The write itself succeeds either way — this is advice for the user, not a
// rejection. The result is never nil, so a caller can put it straight into a
// JSON response and get [] rather than null.
func IsRestartRequiredKeys(settingsType string, patch map[string]interface{}) []string {
keys := []string{}
if settingsType != SettingGlobal {
return keys
}
patched := patchPaths(patch)
for _, watched := range startupOnlySettings {
for _, path := range patched {
if isPathsOverlap(path, watched) {
keys = append(keys, watched)
break
}
}
}
return keys
}
// patchPaths expands a nested settings patch into the dot-separated paths of its
// leaves. An object is descended into rather than reported, so a patch that only
// names sections still resolves to the keys it changes, and a JSON null is a
// leaf because it deletes the key it names.
func patchPaths(patch map[string]interface{}) []string {
paths := []string{}
var walk func(prefix string, node map[string]interface{})
walk = func(prefix string, node map[string]interface{}) {
for key, value := range node {
path := key
if prefix != "" {
path = prefix + "." + key
}
if nested, ok := value.(map[string]interface{}); ok && nested != nil {
walk(path, nested)
continue
}
paths = append(paths, path)
}
}
walk("", patch)
return paths
}
// isPathsOverlap reports whether a patched path and a watched setting can affect
// each other: they are the same key, the patch names something inside the
// watched setting, or the patch names a section the watched setting lives in.
// The last case matters because a patch may replace a whole section, which
// changes every key under it.
func isPathsOverlap(patched, watched string) bool {
return patched == watched ||
strings.HasPrefix(patched, watched+".") ||
strings.HasPrefix(watched, patched+".")
}
// settingsLock serializes read-modify-write access to the on-disk configuration
// files so concurrent admin edits (UpdateSettings) and the node tracker's
// background save (SaveBotNodeConfig) cannot lose each other's updates.
@@ -166,45 +82,19 @@ func GetSettings(settingsType string) ([]byte, error) {
// written the matching in-memory singleton is reloaded so runtime code observes
// the new values.
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
return runSettingsUpdate(func() (*Config, error) {
return updateSettingsLocked(settingsType, patch)
})
}
settingsLock.Lock()
defer settingsLock.Unlock()
// runSettingsUpdate runs fn under settingsLock and then, once the lock is
// released, runs the reload hooks with whatever configuration fn reports (nil
// when the update did not touch config.json).
//
// The hooks deliberately run outside settingsLock. A hook rebuilds controllers,
// which can wait on a network call, while settingsLock is also held by the node
// tracker's background save (SaveBotNodeConfig); holding it across a hook would
// stall node registration behind an unrelated settings edit.
func runSettingsUpdate(fn func() (*Config, error)) error {
cfg, err := func() (*Config, error) {
settingsLock.Lock()
defer settingsLock.Unlock()
return fn()
}()
if err != nil {
return err
}
notifyReload(cfg)
return nil
return updateSettingsLocked(settingsType, patch)
}
// updateSettingsLocked is UpdateSettings without the locking, for callers that
// need to inspect the loaded configuration and write in one critical section
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
//
// It returns the freshly loaded global configuration, or nil when the settings
// type is one of the other files. The caller is responsible for handing that
// value to notifyReload once settingsLock is released — which runSettingsUpdate
// does for every writer.
func updateSettingsLocked(settingsType string, patch map[string]interface{}) (*Config, error) {
func updateSettingsLocked(settingsType string, patch map[string]interface{}) error {
path, err := settingsPath(settingsType)
if err != nil {
return nil, err
return err
}
// Start from whatever is already on disk so nothing is dropped. A missing or
@@ -214,23 +104,23 @@ func updateSettingsLocked(settingsType string, patch map[string]interface{}) (*C
if err == nil {
if len(bytes.TrimSpace(data)) > 0 {
if err := json.Unmarshal(data, &current); err != nil {
return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
}
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
return fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
}
deepMergeSettings(current, patch)
data, err = json.MarshalIndent(current, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
return fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return nil, fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
return fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
}
return reloadSettings(settingsType, path)
@@ -261,19 +151,18 @@ func deepMergeSettings(dst, src map[string]interface{}) {
}
// reloadSettings refreshes the in-memory singleton for the given settings type
// so the running program observes the values just persisted to disk. Only
// config.json has a hook-visible reload, so for SettingGlobal it returns the
// configuration now in effect and for the other types it returns nil.
func reloadSettings(settingsType, path string) (*Config, error) {
// so the running program observes the values just persisted to disk.
func reloadSettings(settingsType, path string) error {
switch settingsType {
case SettingGlobal:
return LoadGlobalConfig(path)
_, err := LoadGlobalConfig(path)
return err
case SettingBotUserConfig:
_, err := LoadBotUserConfig(path)
return nil, err
return err
case SettingBotNodeConfig:
return nil, LoadBotNodeConfig(path)
return LoadBotNodeConfig(path)
default:
return nil, ErrUnsupportedSettingsType
return ErrUnsupportedSettingsType
}
}
+14 -172
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -28,13 +27,13 @@ func TestUpdateSettingsDeepMerge(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
"qq(napcat)": {
"admins": {
"100000001": {
"3526453517": {
"event_status_notify": true,
"event_bot_started": true
}
},
"trustedGroups": {
"200000002": {
"740724778": {
"event_status_notify": true,
"event_bot_started": false
}
@@ -45,7 +44,7 @@ func TestUpdateSettingsDeepMerge(t *testing.T) {
patch := map[string]interface{}{
"qq(napcat)": map[string]interface{}{
"admins": map[string]interface{}{
"100000001": map[string]interface{}{
"3526453517": map[string]interface{}{
"event_status_notify": false, // toggle an existing nested flag
"event_reply": true, // add a key that is not in the file
},
@@ -71,7 +70,7 @@ func TestUpdateSettingsDeepMerge(t *testing.T) {
`"event_status_notify": false`,
`"event_reply": true`,
`"event_bot_started": true`,
`"event_status_notify": true`, // sibling under trustedGroups 200000002 kept
`"event_status_notify": true`, // sibling under trustedGroups 740724778 kept
`"12345"`,
} {
if !strings.Contains(got, want) {
@@ -80,17 +79,16 @@ func TestUpdateSettingsDeepMerge(t *testing.T) {
}
// The in-memory singleton must reflect the merged file too.
users := BotUsers()
if users == nil {
t.Fatal("bot user config not reloaded")
if C_botUserConfig == nil {
t.Fatal("C_botUserConfig not reloaded")
}
if users.QQ.Admins["100000001"].EventStatusNotify {
if C_botUserConfig.QQ.Admins["3526453517"].EventStatusNotify {
t.Error("expected reloaded admin event_status_notify = false")
}
if !users.QQ.Admins["100000001"].EventReply {
if !C_botUserConfig.QQ.Admins["3526453517"].EventReply {
t.Error("expected reloaded admin event_reply = true")
}
if !users.QQ.TrustedGroups["12345"].EventBotStarted {
if !C_botUserConfig.QQ.TrustedGroups["12345"].EventBotStarted {
t.Error("expected new trusted group event_bot_started = true")
}
}
@@ -149,162 +147,6 @@ func TestUpdateSettingsReplacesArraysAndKeepsNumbers(t *testing.T) {
}
}
func TestRestartRequiredKeys(t *testing.T) {
cases := []struct {
name string
settingsType string
patch map[string]interface{}
want []string
}{
{
name: "a runtime switch needs no restart",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"debugMode": true}},
want: []string{},
},
{
name: "the listen port does",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": "9090"}},
want: []string{"system.listenPort"},
},
{
name: "only the startup key of a mixed patch is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090", "debugMode": true},
},
want: []string{"system.listenPort"},
},
{
name: "a top-level path is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{"dataPath": "/srv/data"},
want: []string{"dataPath"},
},
{
name: "results follow the declared order, not the patch order",
settingsType: SettingGlobal,
patch: map[string]interface{}{"dbPath": "/srv/db", "dataPath": "/srv/data"},
want: []string{"dataPath", "dbPath"},
},
{
name: "deleting a startup key with null is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": nil}},
want: []string{"system.listenPort"},
},
{
name: "replacing a whole section reports the startup keys inside it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"webhook": map[string]interface{}{"listenAddr": "127.0.0.1"}},
want: []string{"webhook.listenAddr"},
},
{
// Deleting the section resets the URL to its built-in default.
name: "deleting a section the startup key lives in reports it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"komari": nil},
want: []string{"komari.dashboardURL"},
},
{
name: "deleting a section reports every startup key inside it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"webhook": nil},
want: []string{"webhook.enabled", "webhook.listenAddr", "webhook.listenPort"},
},
{
// An empty object merges nothing, so it changes no key and needs no
// restart — surprising enough to pin.
name: "an empty object changes nothing",
settingsType: SettingGlobal,
patch: map[string]interface{}{"komari": map[string]interface{}{}},
want: []string{},
},
{
// webhook.endpoints must not be mistaken for webhook.enabled.
name: "a sibling subtree is not mistaken for the startup key",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"webhook": map[string]interface{}{
"endpoints": map[string]interface{}{"example": map[string]interface{}{"enabled": true}},
},
},
want: []string{},
},
{
// The Komari credentials are re-read on the next login, so only the
// dashboard URL is startup-only.
name: "komari credentials are not startup-only",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"komari": map[string]interface{}{
"account": map[string]interface{}{"username": "admin", "password": "x"},
},
},
want: []string{},
},
{
name: "controller settings are not startup-only",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"controllerMethod": map[string]interface{}{
"telegram": map[string]interface{}{"enabled": true, "botToken": "t"},
},
},
want: []string{},
},
{
name: "an empty patch reports nothing",
settingsType: SettingGlobal,
patch: map[string]interface{}{},
want: []string{},
},
{
// Only config.json has settings that are read once at startup.
name: "the other settings files never need a restart",
settingsType: SettingBotUserConfig,
patch: map[string]interface{}{"dataPath": "/srv/data"},
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := IsRestartRequiredKeys(tc.settingsType, tc.patch)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("RestartRequiredKeys() = %v, want %v", got, tc.want)
}
if got == nil {
t.Error("the result must never be nil, so it serializes as [] rather than null")
}
})
}
}
// TestRestartRequiredKeysIsAdvisory pins that reporting a startup-only key does
// not stop the write: the caller is told, the file is still updated.
func TestRestartRequiredKeysIsAdvisory(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"listenPort":"8080"}}`)
keys := IsRestartRequiredKeys(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090"},
})
if len(keys) != 1 {
t.Fatalf("expected the listen port to be reported, got %v", keys)
}
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090"},
}); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := Current().System.ListenPort; got != "9090" {
t.Errorf("the update was not applied: listenPort = %q", got)
}
}
func TestSettingsTypeValidation(t *testing.T) {
for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} {
if !IsValidSettingsType(valid) {
@@ -339,8 +181,8 @@ func TestUpdateSettingsRemovesKeysWithNull(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
"qq(napcat)": {
"admins": {
"100000001": { "event_status_notify": true },
"200000002": { "event_status_notify": false }
"3526453517": { "event_status_notify": true },
"740724778": { "event_status_notify": false }
},
"trustedGroups": {
"999": { "event_bot_started": true }
@@ -355,7 +197,7 @@ func TestUpdateSettingsRemovesKeysWithNull(t *testing.T) {
patch := map[string]interface{}{
"qq(napcat)": map[string]interface{}{
"admins": map[string]interface{}{
"100000001": nil,
"3526453517": nil,
},
},
}
@@ -368,10 +210,10 @@ func TestUpdateSettingsRemovesKeysWithNull(t *testing.T) {
t.Fatalf("GetSettings: %v", err)
}
got := string(data)
if strings.Contains(got, "100000001") {
if strings.Contains(got, "3526453517") {
t.Errorf("deleted member still present:\n%s", got)
}
for _, want := range []string{"200000002", `"trustedGroups"`, `"telegram"`} {
for _, want := range []string{"740724778", `"trustedGroups"`, `"telegram"`} {
if !strings.Contains(got, want) {
t.Errorf("unrelated content missing %q:\n%s", want, got)
}
+28 -77
View File
@@ -1,9 +1,6 @@
package config
import (
"sort"
"sync/atomic"
)
import "sort"
// SystemConfig holds system-level configuration.
type SystemConfig struct {
@@ -15,13 +12,12 @@ type SystemConfig struct {
// DebugConfig holds debug-level configuration.
type DebugConfig struct {
ShowOneBotMsg bool `json:"showOneBotMsg"`
ShowOneBotAction bool `json:"showOneBotAction"`
ShowTelegramMsg bool `json:"showTelegramMsg"`
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
ShowNapcatMsg bool `json:"showNapcatMsg"`
ShowNapcatAction bool `json:"showNapcatAction"`
ShowTelegramMsg bool `json:"showTelegramMsg"`
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"`
}
// KomariAccount holds Komari login credentials.
@@ -36,14 +32,14 @@ type KomariConfig struct {
Account KomariAccount `json:"account"`
}
// QQConfig holds QQ (OneBot) Bot controller configuration.
type OneBotConfig struct {
// QQConfig holds QQ (Napcat) Bot controller configuration.
type QQConfig struct {
Markdown bool `json:"markdown"`
Enabled bool `json:"enabled"`
NetworkUseProxy bool `json:"networkUseProxy"`
OneBotAddr string `json:"oneBotAddr"`
OneBotPort string `json:"oneBotPort"`
OneBotToken string `json:"oneBotToken"`
NapcatAddr string `json:"napcatAddr"`
NapcatPort string `json:"napcatPort"`
NapcatToken string `json:"napcatToken"`
BotQQID int64 `json:"botQQID"`
ListenMethod string `json:"listenMethod"`
}
@@ -97,17 +93,17 @@ type WebhookConfig struct {
// ControllerMethodConfig holds all controller method configurations.
type ControllerMethodConfig struct {
QQ_OneBot OneBotConfig `json:"qq_onebot"`
Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"`
Webhook WebhookConfig `json:"webhook"`
QQ QQConfig `json:"qq(napcat)"`
Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"`
Webhook WebhookConfig `json:"webhook"`
}
// WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are
// keyed by name under webhook.endpoints; the name is the last path segment of
// the endpoint's URL, so an endpoint named "example" is served at
// POST /api/webhook/post/example. One endpoint per external application and target
// POST /api/webhook/example. One endpoint per external application and target
// channel group keeps their tokens and recipients apart.
type WebhookEndpointConfig struct {
// Enabled controls whether the endpoint accepts requests. A disabled
@@ -120,7 +116,7 @@ type WebhookEndpointConfig struct {
Token string `json:"token"`
// NotifyPipes lists the notification channels the alert is delivered to, by
// controller name (e.g. "qq(onebot)", "telegram", "email", "ntfy",
// controller name (e.g. "qq(napcat)", "telegram", "email", "ntfy",
// "webhook").
NotifyPipes []string `json:"notifyPipes"`
}
@@ -140,11 +136,10 @@ type WebhookReceiverConfig struct {
// GetWebhookEndpoint returns the incoming webhook endpoint registered under the
// given name, and whether such an endpoint exists.
func GetWebhookEndpoint(name string) (WebhookEndpointConfig, bool) {
cfg := Current()
if cfg == nil {
if C_globalConfig == nil {
return WebhookEndpointConfig{}, false
}
endpoint, ok := cfg.Webhook.Endpoints[name]
endpoint, ok := C_globalConfig.Webhook.Endpoints[name]
return endpoint, ok
}
@@ -170,19 +165,7 @@ type Config struct {
DBPath string `json:"dbPath"`
}
// globalConfig holds the configuration currently in effect. It is replaced as a
// whole by LoadGlobalConfig — and therefore by every settings update — and never
// mutated in place, so a reader that loads the pointer always observes a fully
// initialized Config. Read it through Current rather than caching the result: a
// cached pointer stops tracking reloads.
var globalConfig atomic.Pointer[Config]
// Current returns the configuration currently in effect, or nil before the
// first successful LoadGlobalConfig. It is safe to call from any goroutine, and
// must be called on every use rather than stored, so the caller sees reloads.
func Current() *Config {
return globalConfig.Load()
}
var C_globalConfig *Config
// BotUserOptions holds per-member options stored in bot_user_config.json.
type BotUserOptions struct {
@@ -217,7 +200,7 @@ func (m BotUserMembers) IDs() []string {
return ids
}
// BotUser_QQConfig holds the admins and trusted groups for the QQ (OneBot) bot.
// BotUser_QQConfig holds the admins and trusted groups for the QQ (Napcat) bot.
type BotUser_QQConfig struct {
Admins BotUserMembers `json:"admins"`
TrustedGroups BotUserMembers `json:"trustedGroups"`
@@ -233,20 +216,11 @@ type BotUser_TelegramConfig struct {
// and trusted groups (chat targets) for each bot channel separately from the
// main config.json.
type BotUserConfig struct {
QQ BotUser_QQConfig `json:"qq_onebot"`
QQ BotUser_QQConfig `json:"qq(napcat)"`
Telegram BotUser_TelegramConfig `json:"telegram"`
}
// botUserConfig mirrors bot_user_config.json the same way globalConfig mirrors
// config.json: replaced wholesale on reload and read through BotUsers.
var botUserConfig atomic.Pointer[BotUserConfig]
// BotUsers returns the bot user configuration currently in effect, or nil
// before the first successful LoadBotUserConfig. Like Current it must be called
// on every use rather than stored.
func BotUsers() *BotUserConfig {
return botUserConfig.Load()
}
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;
@@ -258,35 +232,12 @@ type BotNodeOptions struct {
// bool) lets an absent field be told apart from an explicit false, and
// omitempty keeps untouched nodes stored as {}.
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
// FlapTolerant is the number of seconds a node's Offline notification is
// held in the hope the node returns. A node that comes back online within
// this window produces no notification at all; one still offline when the
// window elapses reports Offline as usual. Zero (or an absent field)
// disables inhibition, which keeps the notify-immediately behavior. It is
// a float64 rather than an int so a hand-written fractional value such as
// 2.5 parses instead of failing the whole config load at startup, and it
// is not a pointer because 0 already means "off", unlike
// EnableStatusNotify whose default is true.
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
}
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
// options.
type BotNodeMembers map[string]BotNodeOptions
// NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
// The map is rebuilt rather than mutated on every load, so the pointer can be
// swapped atomically; read it through BotNodes.
var botNodeConfig atomic.Pointer[BotNodeMembers]
// BotNodes returns the per-node options currently in effect, or nil before the
// first LoadBotNodeConfig. Like Current it must be called on every use rather
// than stored.
func BotNodes() BotNodeMembers {
nodes := botNodeConfig.Load()
if nodes == nil {
return nil
}
return *nodes
}
// C_botNodeConfig is the global singleton mirroring bot_node_config.json,
// populated by LoadBotNodeConfig.
var C_botNodeConfig BotNodeMembers
+29 -27
View File
@@ -37,11 +37,10 @@ var webhookEndpointFields = map[string]func(interface{}) bool{
// configuration.
func WebhookEndpoints() map[string]WebhookEndpointConfig {
endpoints := map[string]WebhookEndpointConfig{}
cfg := Current()
if cfg == nil {
if C_globalConfig == nil {
return endpoints
}
for name, endpoint := range cfg.Webhook.Endpoints {
for name, endpoint := range C_globalConfig.Webhook.Endpoints {
endpoints[name] = endpoint
}
return endpoints
@@ -60,12 +59,13 @@ func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
return err
}
return runSettingsUpdate(func() (*Config, error) {
if _, exists := webhookEndpoint(name); exists {
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
})
settingsLock.Lock()
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
}
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
@@ -83,36 +83,38 @@ func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
}
return runSettingsUpdate(func() (*Config, error) {
if _, exists := webhookEndpoint(name); !exists {
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
})
settingsLock.Lock()
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); !exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
}
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
// name. The endpoint stops accepting requests as soon as the configuration is
// reloaded.
func DeleteWebhookEndpoint(name string) error {
return runSettingsUpdate(func() (*Config, error) {
if _, exists := webhookEndpoint(name); !exists {
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
})
settingsLock.Lock()
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); !exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
}
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
}
// webhookEndpoint returns the named endpoint held by the loaded configuration.
// No extra lock is needed to read it: a reload replaces the whole configuration
// rather than mutating it in place, and Current publishes the replacement
// atomically, so the value read is always from one complete version.
// No lock is needed to read it: a reload replaces the whole configuration
// rather than mutating it in place, and the value is read from whichever
// version is current.
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
cfg := Current()
if cfg == nil {
if C_globalConfig == nil {
return WebhookEndpointConfig{}, false
}
endpoint, exists := cfg.Webhook.Endpoints[name]
endpoint, exists := C_globalConfig.Webhook.Endpoints[name]
return endpoint, exists
}
+1 -1
View File
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
## Features
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load. The pencil on a card opens a per-node options dialog holding the *status notify* switch and the *flap inhibition* window, persisted to `bot_node_config.json`. Searchable, auto-refreshes.
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes.
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
+3
View File
@@ -1473,6 +1473,7 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1595,6 +1596,7 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -1669,6 +1671,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42",
-4
View File
@@ -13,10 +13,6 @@ export const serverApi = {
// /api/settings/get?type=… / /api/settings/set?type=…
// get → { success, message, data: { config } }.
// set → { success, message, data: { type, restartRequired } }, where
// restartRequired lists the keys the update changed that are only read at
// startup, so the caller can say which edits are not live yet. It is
// always an array, empty when the whole update took effect.
// `type` is one of global | bot_user_config | bot_node_config.
// For set, pass a partial object; a JSON null value removes that key.
export const settingsApi = {
+2 -9
View File
@@ -133,15 +133,8 @@ async function save() {
const patch = wrapRoot(props.section, nest(obj));
saving.value = true;
try {
const res = await settingsApi.set('global', patch);
// The backend reports the keys it wrote that are only read at startup.
// A plain "saved" would suggest those are live too.
const pending = (res && res.data && res.data.restartRequired) || [];
if (pending.length) {
toast.warn(`${props.section.title} saved — restart to apply: ${pending.join(', ')}`, 7000);
} else {
toast.success(`${props.section.title} saved`);
}
await settingsApi.set('global', patch);
toast.success(`${props.section.title} saved`);
emit('saved');
} catch (e) {
toast.error('Failed to save: ' + e.message);
+20 -156
View File
@@ -4,7 +4,6 @@ import { serverApi, settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js';
import { formatBytes, shortUUID } from '../utils/fmt.js';
import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue';
function percent(m) {
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
@@ -14,20 +13,9 @@ const query = ref('');
const loading = ref(true);
const failed = ref(false);
const entries = ref([]);
const saving = reactive(new Set());
let timer = null;
// The per-node options dialog. Its form is a copy of the node's settings taken
// when the dialog opens, so the 15s card refresh behind it cannot disturb an
// open editor, and closing without saving leaves the node untouched.
const editing = reactive({
open: false,
uuid: '',
name: '',
notify: true,
flap: '',
saving: false
});
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return entries.value;
@@ -69,8 +57,7 @@ async function load() {
online: !!s.online,
report: s.report || null,
info: infoEntry.info || null,
notify: conf.enableStatusNotify !== false,
flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0
notify: conf.enableStatusNotify !== false
});
}
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
@@ -84,42 +71,21 @@ async function load() {
}
}
function openOptions(entry) {
editing.uuid = entry.uuid;
editing.name = entry.name;
editing.notify = entry.notify;
editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : '';
editing.open = true;
}
// saveOptions writes both options in one patch. A flap window that is not a
// positive number disables inhibition, which is sent as a JSON null so the key
// is removed rather than stored as an explicit 0 — the file then keeps matching
// its documented "absent means off" default.
async function saveOptions() {
const typed = Number(editing.flap);
const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0;
const uuid = editing.uuid;
editing.saving = true;
async function toggleNotify(entry) {
const next = !entry.notify;
const prev = entry.notify;
entry.notify = next;
saving.add(entry.uuid);
try {
const patch = {};
patch[uuid] = {
enableStatusNotify: editing.notify,
flap_tolerant: seconds > 0 ? seconds : null
};
patch[entry.uuid] = { enableStatusNotify: next };
await settingsApi.set('bot_node_config', patch);
const entry = entries.value.find((e) => e.uuid === uuid);
if (entry) {
entry.notify = editing.notify;
entry.flapTolerant = seconds;
}
toast.success(`${editing.name}: options saved`);
editing.open = false;
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
} catch (e) {
entry.notify = prev;
toast.error('Save failed: ' + e.message);
} finally {
editing.saving = false;
saving.delete(entry.uuid);
}
}
@@ -145,6 +111,7 @@ onBeforeUnmount(() => {
<div>
<p class="eyebrow">Overview</p>
<h1>Nodes</h1>
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
</div>
<div class="head-actions">
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
@@ -240,60 +207,17 @@ onBeforeUnmount(() => {
<span>·</span>
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
</div>
<div class="opts">
<span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
<i class="fas fa-bell-slash" /> Notify off
</span>
<span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
<i class="fas fa-shield-halved" /> Flap {{ e.flapTolerant }}s
</span>
<button class="icon-btn" title="Node options" @click="openOptions(e)">
<i class="fas fa-pen" />
</button>
<div class="notify">
<Toggle
:model-value="e.notify"
label="Status notify"
:disabled="saving.has(e.uuid)"
@update:model-value="toggleNotify(e)"
/>
</div>
</footer>
</article>
</div>
<Modal :open="editing.open" title="Node options" @close="editing.open = false">
<div class="node-head">
<p class="node-name">{{ editing.name }}</p>
<p class="node-uuid mono">{{ editing.uuid }}</p>
</div>
<div class="field">
<label>Status notify</label>
<Toggle v-model="editing.notify" label="Report online / offline changes" />
<p class="help">With this off, the node's status changes are not delivered to any pipes.</p>
</div>
<div class="field">
<label>Flap inhibition</label>
<div class="flap-row">
<input
v-model="editing.flap"
class="input flap-input"
type="number"
min="0"
max="86400"
step="1"
inputmode="numeric"
placeholder="0"
@keydown.enter="saveOptions"
/>
<span class="flap-unit">seconds</span>
</div>
<p class="help">How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.</p>
</div>
<template #foot>
<button class="btn btn-ghost" @click="editing.open = false">Cancel</button>
<button class="btn btn-primary" :disabled="editing.saving" @click="saveOptions">
<span v-if="editing.saving" class="spinner" style="width:12px;height:12px" />
<i v-else class="fas fa-check" /> Save
</button>
</template>
</Modal>
</section>
</template>
@@ -374,67 +298,7 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
}
.opts {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
/* Stay right-aligned even when the spec block is absent. */
margin-left: auto;
}
.opts .chip { white-space: nowrap; }
/* --- Node options dialog ------------------------------------------------ */
.node-head {
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.node-name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.node-uuid {
font-size: 11.5px;
color: var(--text-3);
margin-top: 3px;
overflow-wrap: anywhere;
}
/* The modal body has no grid to space stacked fields. */
.field + .field { margin-top: 20px; }
.flap-row {
display: flex;
align-items: center;
gap: 9px;
}
.flap-input {
width: 110px;
text-align: center;
font-variant-numeric: tabular-nums;
/* Match the plain hand-rolled field look: the rest of the console renders
numbers as ordinary inputs, so the spinner controls would stand out. */
-moz-appearance: textfield;
appearance: textfield;
}
.flap-input::-webkit-outer-spin-button,
.flap-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.flap-unit {
font-size: 12.5px;
color: var(--text-3);
}
.notify { display: flex; align-items: center; }
.refresh-hint {
font-size: 12px;
+17 -17
View File
@@ -11,7 +11,7 @@ const sections = [
{
id: 'system',
title: 'System',
hint: 'HTTP listener and global runtime switches. A changed listen address or port applies on restart.',
hint: 'HTTP listener and global runtime switches.',
root: ['system'],
fields: [
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
@@ -23,22 +23,21 @@ const sections = [
{
id: 'debug',
title: 'Debug logging',
hint: '',
hint: 'Per-module verbosity for the bot pipes.',
root: ['debug'],
fields: [
{ key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' },
{ key: 'showOneBotAction', type: 'bool', label: 'OneBot actions' },
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
{ key: 'showFlapTolerantEcho', type: 'bool', label: 'Flap inhibition echo' },
{ key: 'oneBotIgnoreSelfMsg', type: 'bool', label: 'Ignore OneBot self messages' }
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
]
},
{
id: 'komari',
title: 'Komari dashboard',
hint: 'Connection the monitor reads node data from. The URL applies on restart; the account is re-read on the next login.',
hint: 'Connection the monitor reads node data from. Takes effect on restart.',
root: ['komari'],
fields: [
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
@@ -61,16 +60,16 @@ const sections = [
]
},
{
id: 'qq_onebot',
title: 'QQ controller (OneBot)',
root: ['controllerMethod', 'qq_onebot'],
id: 'qq',
title: 'QQ controller (NapCat)',
root: ['controllerMethod', 'qq(napcat)'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
{ key: 'oneBotPort', type: 'text', label: 'OneBot port' },
{ key: 'oneBotToken', type: 'password', label: 'OneBot token' },
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
]
@@ -81,7 +80,7 @@ const sections = [
root: ['controllerMethod', 'telegram'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'botToken', type: 'password', label: 'Bot token' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
@@ -93,7 +92,7 @@ const sections = [
root: ['controllerMethod', 'email'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
@@ -110,7 +109,7 @@ const sections = [
root: ['controllerMethod', 'ntfy'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'server', type: 'text', label: 'Server' },
{ key: 'topic', type: 'text', label: 'Topic' },
@@ -125,7 +124,7 @@ const sections = [
root: ['controllerMethod', 'webhook'],
fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Format the alert body with code blocks and inline code in the posted payload.' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'url', type: 'text', label: 'URL' },
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
@@ -171,6 +170,7 @@ onMounted(load);
<div>
<p class="eyebrow">Configuration</p>
<h1>System settings</h1>
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
</div>
<div class="head-actions">
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
+1 -1
View File
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue';
const CHANNELS = [
{ key: 'qq_onebot', label: 'QQ (OneBot)', hint: 'Members are identified by QQ number / group number' },
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
];
+1 -1
View File
@@ -10,7 +10,7 @@ import Modal from '../components/Modal.vue';
// controller names in config.json (controllerMethod.*), which is exactly what
// the backend matches notifyPipes against.
const CHANNELS = [
{ key: 'qq_onebot', label: 'QQ_OneBot' },
{ key: 'qq(napcat)', label: 'QQ' },
{ key: 'telegram', label: 'Telegram' },
{ key: 'email', label: 'Email' },
{ key: 'ntfy', label: 'ntfy' },
+8 -8
View File
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
var SoftwareInfo = SoftwareInfoStr{
Name: "Nukumizu",
Version: "0.2.3",
Version: "0.2.0",
Developer: "Madobi Nanami",
BuildVer: 8,
BuildVer: 5,
CommitHash: "unknown",
Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "pre-release",
@@ -24,13 +24,13 @@ var SoftwareInfo = SoftwareInfoStr{
}
type ConfigPathStr struct {
Global string
BotUserConfig string
BotNodeConfig string
Global string
BotUserConfig string
BotNodeConfig string
}
var ConfigPath = ConfigPathStr{
Global: "config.json",
BotUserConfig: "bot_user_config.json",
BotNodeConfig: "bot_node_config.json",
Global: "config.json",
BotUserConfig: "bot_user_config.json",
BotNodeConfig: "bot_node_config.json",
}
+1 -1
View File
@@ -1,6 +1,6 @@
module nukumizu-backend
go 1.27.3
go 1.27.1
require (
github.com/go-telegram/bot v1.25.0
+2 -16
View File
@@ -51,27 +51,13 @@ func setupAdminToken() {
utils.AddToken("test-admin-token", 1, "admin", "tester")
}
// decodeResponse decodes a success envelope and returns its data object, which
// the getInfo and getStatus handlers key by uuid. Responses are wrapped by
// utils.SendSuccessResponse as {success, message?, data:{...}}, the shape the
// console reads as `response.data[uuid]` (see frontend/src/api/index.js), so
// the tests index the returned map by uuid rather than by envelope key.
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]json.RawMessage {
t.Helper()
var body struct {
Success bool `json:"success"`
Data map[string]json.RawMessage `json:"data"`
}
var body map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
}
if !body.Success {
t.Fatalf("response is not a success envelope: %s", w.Body.String())
}
if body.Data == nil {
t.Fatalf("response has no data object: %s", w.Body.String())
}
return body.Data
return body
}
func TestServerGetInfoAll(t *testing.T) {
+1 -8
View File
@@ -47,10 +47,6 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
// {"system": {"debugMode": true}}
//
// Multiple entries may be given at once; only the provided keys are changed.
//
// The response carries data.restartRequired: the keys the update changed that
// are only read at startup, so the caller can say which edits are not live yet.
// It is empty for an update that took effect in full.
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "admin") {
return
@@ -84,10 +80,7 @@ func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
return
}
// The write landed either way. This only tells the caller which of the keys
// it changed will not be live until the program is restarted.
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
"type": settingsType,
"restartRequired": config.IsRestartRequiredKeys(settingsType, patch),
"type": settingsType,
})
}
-128
View File
@@ -1,128 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"nukumizu-backend/config"
"nukumizu-backend/global"
)
// tempConfigFile points one of the settings paths at a throwaway file, so a
// test can drive the settings API without touching the config files in the
// working directory.
func tempConfigFile(t *testing.T, field *string, content string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
original := *field
*field = path
t.Cleanup(func() { *field = original })
}
// settingsSetRequest builds an authenticated POST for the settings endpoint.
func settingsSetRequest(t *testing.T, settingsType, body string) *http.Request {
t.Helper()
req := httptest.NewRequest(
http.MethodPost,
"/api/settings/set?type="+settingsType,
strings.NewReader(body),
)
req.Header.Set("X-Token", "test-admin-token")
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
return req
}
// settingsSetResponse is the envelope /api/settings/set answers with.
type settingsSetResponse struct {
Success bool `json:"success"`
Data struct {
Type string `json:"type"`
RestartRequired []string `json:"restartRequired"`
} `json:"data"`
}
func decodeSettingsSetResponse(t *testing.T, w *httptest.ResponseRecorder) settingsSetResponse {
t.Helper()
var body settingsSetResponse
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
}
return body
}
// TestSettingsSetReportsStartupOnlyKeys covers the field the console reads to
// tell the user which of their edits are not live yet.
func TestSettingsSetReportsStartupOnlyKeys(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false,"listenPort":"8080"}}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "global",
`{"system":{"listenPort":"9090","debugMode":true}}`))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
body := decodeSettingsSetResponse(t, w)
if !body.Success {
t.Fatalf("not a success envelope: %s", w.Body.String())
}
if body.Data.Type != "global" {
t.Errorf("data.type = %q, want global", body.Data.Type)
}
if len(body.Data.RestartRequired) != 1 || body.Data.RestartRequired[0] != "system.listenPort" {
t.Errorf("restartRequired = %v, want [system.listenPort]", body.Data.RestartRequired)
}
// Being reported as startup-only must not stop the write.
data, err := config.GetSettings(config.SettingGlobal)
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
for _, want := range []string{`"listenPort": "9090"`, `"debugMode": true`} {
if !strings.Contains(string(data), want) {
t.Errorf("the patch was not applied, missing %s:\n%s", want, data)
}
}
}
// TestSettingsSetRestartRequiredIsAlwaysAnArray pins the shape a client
// iterates over: an update with nothing to report must answer [] and not null.
func TestSettingsSetRestartRequiredIsAlwaysAnArray(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "global", `{"system":{"debugMode":true}}`))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"restartRequired":[]`) {
t.Errorf("restartRequired should serialize as an empty array: %s", w.Body.String())
}
}
// TestSettingsSetRejectsUnknownType keeps the failure path intact now that the
// success path computes an extra field.
func TestSettingsSetRejectsUnknownType(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "nonsense", `{}`))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
+2 -2
View File
@@ -24,12 +24,12 @@ type webhookRequest struct {
Content string `json:"content"`
}
// WebhookHandler handles POST /api/webhook/post/{name}, the incoming webhook API
// WebhookHandler handles POST /api/webhook/{name}, the incoming webhook API
// served on its own listener (see webhook in config.json). The path segment
// selects the endpoint, which carries the token to present and the notification
// channels to deliver to:
//
// POST /api/webhook/post/example
// POST /api/webhook/example
// {"token": "...", "subject": "...", "content": "..."}
//
// The alert is rendered per channel and sent through every channel the endpoint
+11 -247
View File
@@ -3,11 +3,8 @@ package controller
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
@@ -28,7 +25,7 @@ type Command struct {
// Message represents a message to be sent by a controller.
type Message struct {
Source string // The source pipe (e.g., "telegram", "qq", "onebot")
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
@@ -101,45 +98,17 @@ type Controller interface {
// BotController is implemented by controllers that act as chat bots and can
// deliver arbitrary messages, such as the bot initialization message on startup.
// Only bot-type pipes (QQ_OneBot, Telegram) implement it; notification-only
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only
// pipes (email, ntfy, webhook) do not.
type BotController interface {
Controller
SendMessage(message Message) error
}
// flapPending is an Offline notification being held back for a node's
// flap_tolerant window, in the hope that the node returns before the deadline.
// The held change is kept by value so a later mutation of the caller's copy
// cannot reach it.
type flapPending struct {
timer *time.Timer
change node.StatusChange
}
// Manager manages all controller instances and routes events.
type Manager struct {
mu sync.RWMutex
controllers map[string]Controller
// builtFrom records the controllerMethod section the registered controllers
// were built from, so a settings update that concerns them can be told apart
// from one that does not. It is read on the settings-update goroutine and
// written when the set is replaced.
builtFrom atomic.Pointer[config.ControllerMethodConfig]
// flapMu guards flapPending, the Offline notifications currently held for
// flap inhibition, keyed by node UUID. It is a separate lock from mu and is
// never held while mu is taken: a held notification is dispatched by a timer
// goroutine, which must not block behind a routing swap, and dispatching
// (network I/O per controller) must not happen while holding the flap lock.
flapMu sync.Mutex
flapPending map[string]*flapPending
// flapToleranceFor overrides the per-node flap window. It is nil in
// production, where the value comes from bot_node_config.json through
// config.NodeFlapTolerant; tests set it to keep their timings hermetic.
flapToleranceFor func(uuid string) time.Duration
}
var globalManager *Manager
@@ -148,7 +117,6 @@ var globalManager *Manager
func InitManager() {
globalManager = &Manager{
controllers: make(map[string]Controller),
flapPending: make(map[string]*flapPending),
}
postLog.Info("Controller manager initialized")
}
@@ -158,74 +126,16 @@ func GetManager() *Manager {
return globalManager
}
// NeedsRebuild reports whether next differs from the controllerMethod section
// the registered controllers were built from. A manager with no controllers yet
// always reports true, so the first call installs the initial set.
//
// Controllers are rebuilt wholesale rather than reconfigured in place: each one
// reads its settings into fields at construction, and two of them own
// connections that cannot be re-pointed (the OneBot WebSocket listener is
// stopped through a sync.Once, the Telegram polling context is created with the
// controller). Replacing the set keeps every channel on the same footing.
func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool {
built := m.builtFrom.Load()
if built == nil {
return true
}
// The section carries a header map and a recipient slice, so it is not
// comparable with ==.
return !reflect.DeepEqual(*built, next)
}
// ReplaceAll stops every registered controller and swaps in next, which the
// caller built from method. It is the only way controllers are installed, at
// startup and after a settings change alike.
//
// The swap happens under the registry lock so routing flips to the new set
// atomically; stopping and starting happen outside it. Both can block — Stop
// closes sockets, Start performs a handshake — and holding m.mu across them
// would stall every notification for the duration.
func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodConfig) {
// Register adds a controller to the manager.
func (m *Manager) Register(c Controller) {
m.mu.Lock()
previous := m.controllers
m.controllers = make(map[string]Controller, len(next))
for _, ctrl := range next {
m.controllers[ctrl.Name()] = ctrl
}
m.mu.Unlock()
m.builtFrom.Store(&method)
names := make([]string, 0, len(next))
for _, ctrl := range next {
names = append(names, ctrl.Name())
}
postLog.Info("Controller set installed: " + strings.Join(names, ", "))
for _, ctrl := range previous {
ctrl.Stop()
}
// Start off the calling goroutine, the way startup does: Telegram's getMe
// and the OneBot WebSocket handshake would otherwise hold the settings
// request open for as long as they take. The new controllers are already
// routable, and each one can send before Start returns.
for _, ctrl := range next {
go func(ctrl Controller) {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Controller %s panicked on start: %v", ctrl.Name(), r))
}
}()
if err := ctrl.Start(); err != nil {
postLog.Error(fmt.Sprintf("Failed to start controller %s: %v", ctrl.Name(), err))
}
}(ctrl)
}
defer m.mu.Unlock()
m.controllers[c.Name()] = c
postLog.Info("Controller registered: " + c.Name())
}
// ShowBotInitMessage sends the bot initialization message to all enabled
// bot controllers (QQ_OneBot and Telegram). Notification-only pipes that do
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
// not implement BotController are skipped. The message is typed
// MessageTypeBotStarted so each controller can honor its members' per-recipient
// EventBotStarted opt-out. It is rendered once per controller because the
@@ -234,7 +144,7 @@ func (m *Manager) ShowBotInitMessage() {
m.mu.RLock()
defer m.mu.RUnlock()
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams()
for _, ctrl := range m.controllers {
@@ -266,7 +176,7 @@ func (m *Manager) ShowBotServerList() {
m.mu.RLock()
defer m.mu.RUnlock()
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
for _, ctrl := range m.controllers {
@@ -292,37 +202,12 @@ func (m *Manager) ShowBotServerList() {
// 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.
//
// It also applies flap inhibition, the counterpart of the allow-list and read
// from the same file: an Offline notification for a node with a positive
// flap_tolerant is held back for that many seconds instead of being broadcast
// (see holdOffline). Because both gates live here, every controller is subject
// to them, chat bots and notification pipes alike.
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
}
switch change.Event {
case "Offline":
if tolerance := m.flapTolerance(change.UUID); tolerance > 0 {
m.holdOffline(change, tolerance)
return
}
case "Online":
// A node that returns while its Offline is still held flaps: both
// halves of the pair are suppressed, and nothing is sent at all.
if m.releaseHeldOffline(change.UUID) {
return
}
}
m.dispatchStatusChange(change)
}
// dispatchStatusChange hands a change to every enabled controller.
func (m *Manager) dispatchStatusChange(change node.StatusChange) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -336,116 +221,6 @@ func (m *Manager) dispatchStatusChange(change node.StatusChange) {
}
}
// flapTolerance returns the flap-inhibition window for a node, honoring the
// test override when one is set.
func (m *Manager) flapTolerance(uuid string) time.Duration {
if m.flapToleranceFor != nil {
return m.flapToleranceFor(uuid)
}
return config.NodeFlapTolerant(uuid)
}
// holdOffline holds an Offline notification for the given window rather than
// broadcasting it. If the node returns within the window, releaseHeldOffline
// claims the held notification first and nothing is sent; otherwise the timer
// claims it and the Offline goes out late (see fireHeldOffline).
//
// A held notification is claimed by removing it from flapPending under flapMu,
// and the claim is decided by the identity of the map entry. That is what keeps
// the two racers — the timer and a returning Online — from both winning:
// whichever gets there first deletes the entry, and the loser finds it gone.
func (m *Manager) holdOffline(change node.StatusChange, tolerance time.Duration) {
m.flapMu.Lock()
if m.flapPending == nil {
m.flapPending = make(map[string]*flapPending)
}
// Defensive: the tracker reports transitions, so a second Offline should
// not arrive for a node already held, but if one does the older hold is
// superseded — its callback loses the claim below and drops the change.
if old := m.flapPending[change.UUID]; old != nil {
old.timer.Stop()
}
held := &flapPending{change: change}
held.timer = time.AfterFunc(tolerance, func() {
m.fireHeldOffline(change.UUID, held)
})
m.flapPending[change.UUID] = held
m.flapMu.Unlock()
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Offline for node %s held for %s (flap tolerance)", change.UUID, tolerance))
}
}
// flapEchoEnabled reports whether the flap-inhibition log lines are emitted,
// per the ShowFlapTolerantEcho debug flag. config.Current is nil before the
// first configuration load — which includes every unit test that drives the
// manager directly — so it is checked before the flag is read, the way the
// other per-module debug flags are (see internal/controller/pipes/qq_onebot).
func flapEchoEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowFlapTolerantEcho
}
// fireHeldOffline dispatches a held Offline once its window has elapsed. It
// claims the entry first and returns without sending if the hold was already
// released or superseded, so a node that came back in time never reports an
// Offline.
func (m *Manager) fireHeldOffline(uuid string, held *flapPending) {
m.flapMu.Lock()
if m.flapPending[uuid] != held {
// Already claimed by a returning Online, or replaced by a newer hold.
m.flapMu.Unlock()
return
}
delete(m.flapPending, uuid)
m.flapMu.Unlock()
// Re-check the allow-list: it may have been edited while the change was
// held, and years-long holds are possible in principle.
if !config.NodeStatusNotifyEnabled(uuid) {
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Held status change for node %s dropped: enableStatusNotify is not enabled", uuid))
}
return
}
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Node %s still offline after flap tolerance; reporting it now", uuid))
}
m.dispatchStatusChange(held.change)
}
// releaseHeldOffline claims and cancels a held Offline notification, reporting
// whether one was still pending. A true result means the node returned within
// its flap window, so the caller must suppress the Online as well.
//
// Stop's return value is deliberately ignored: false also covers "the timer has
// started firing but has not yet taken flapMu", which is not the same as
// "already dispatched". Removing the entry is what decides the race, and it
// makes the only possible outcomes "both halves sent" (the timer won, the node
// really did stay down past the deadline) or "neither sent" — never an Offline
// with no matching Online.
func (m *Manager) releaseHeldOffline(uuid string) bool {
m.flapMu.Lock()
defer m.flapMu.Unlock()
if m.flapPending == nil {
return false
}
held := m.flapPending[uuid]
if held == nil {
return false
}
held.timer.Stop()
delete(m.flapPending, uuid)
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Node %s returned within flap tolerance; suppressing both halves", uuid))
}
return true
}
// NotifyAlert delivers an alert to the named pipes only, and returns the names
// of the pipes it was handed to. A pipe that is unknown, disabled or fails to
// send is reported through the returned error instead of stopping the delivery
@@ -499,19 +274,8 @@ func (m *Manager) IsMarkdown(pipeName string) bool {
return ctrl.IsMarkdown()
}
// StopAll stops all registered controllers. Held Offline notifications are
// dropped first: their timers would otherwise fire against a shutting-down
// controller set. A timer that has already claimed its entry can still be
// dispatching concurrently, which is harmless — it reads the same registry
// under the same lock.
// StopAll stops all registered controllers.
func (m *Manager) StopAll() {
m.flapMu.Lock()
for uuid, held := range m.flapPending {
held.timer.Stop()
delete(m.flapPending, uuid)
}
m.flapMu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
for _, ctrl := range m.controllers {
-446
View File
@@ -1,446 +0,0 @@
package controller
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
)
// fakeController records the lifecycle calls it receives so a test can assert
// what ReplaceAll did to it. Start signals on started, because ReplaceAll starts
// controllers off the calling goroutine.
type fakeController struct {
name string
started chan struct{}
mu sync.Mutex
starts int
stops int
}
func newFake(name string) *fakeController {
return &fakeController{name: name, started: make(chan struct{}, 4)}
}
func (f *fakeController) Name() string { return f.name }
func (f *fakeController) IsEnabled() bool { return true }
func (f *fakeController) IsMarkdown() bool { return false }
func (f *fakeController) Start() error {
f.mu.Lock()
f.starts++
f.mu.Unlock()
select {
case f.started <- struct{}{}:
default:
}
return nil
}
func (f *fakeController) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
f.stops++
}
func (f *fakeController) lifecycle() (starts, stops int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.starts, f.stops
}
func (f *fakeController) SendStatusChange(node.StatusChange) error { return nil }
func (f *fakeController) SendServerList(string, string) error { return nil }
func (f *fakeController) SendExecuteResult(string, string, string, string) error { return nil }
func (f *fakeController) SendAlert(Alert) error { return nil }
// waitStarted blocks until the controller's Start has run.
func (f *fakeController) waitStarted(t *testing.T) {
t.Helper()
select {
case <-f.started:
case <-time.After(5 * time.Second):
t.Fatalf("controller %s was never started", f.name)
}
}
func newTestManager() *Manager {
return &Manager{controllers: make(map[string]Controller)}
}
// routedTo returns the controller the manager currently routes the given name
// to.
func (m *Manager) routedTo(name string) Controller {
m.mu.RLock()
defer m.mu.RUnlock()
return m.controllers[name]
}
// heldOffline returns the Offline notification the manager is currently holding
// for the given node, if any. It lets a test drive the hold/fire/release race
// deterministically instead of racing a real timer.
func (m *Manager) heldOffline(uuid string) *flapPending {
m.flapMu.Lock()
defer m.flapMu.Unlock()
return m.flapPending[uuid]
}
// recordingController keeps every status change it is handed, so a test can
// assert on what actually reached the controllers. The embedded fakeController
// satisfies the rest of the Controller interface.
type recordingController struct {
*fakeController
changes chan node.StatusChange
}
func newRecording(name string) *recordingController {
return &recordingController{
fakeController: newFake(name),
changes: make(chan node.StatusChange, 16),
}
}
func (r *recordingController) SendStatusChange(change node.StatusChange) error {
r.changes <- change
return nil
}
// awaitChange blocks until a status change reaches the controller, failing the
// test rather than hanging if none does.
func awaitChange(t *testing.T, rec *recordingController, what string) node.StatusChange {
t.Helper()
select {
case change := <-rec.changes:
return change
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for the %s notification", what)
return node.StatusChange{}
}
}
// expectNoChange asserts that no status change reaches the controller for the
// given window. Only a negative assertion needs a real wait; the manager has no
// observable "decided to stay silent" event.
func expectNoChange(t *testing.T, rec *recordingController, window time.Duration, what string) {
t.Helper()
select {
case change := <-rec.changes:
t.Fatalf("unexpected %s notification: %+v", what, change)
case <-time.After(window):
}
}
func offlineChange(uuid string) node.StatusChange {
return node.StatusChange{Event: "Offline", UUID: uuid, Name: uuid, OldOnline: true, NewOnline: false}
}
func onlineChange(uuid string) node.StatusChange {
return node.StatusChange{Event: "Online", UUID: uuid, Name: uuid, OldOnline: false, NewOnline: true}
}
// flapTestManager returns a manager with a recording controller installed and
// the flap window stubbed to tolerance, so the tests never touch the global
// bot_node_config.json or wait on real seconds. ReplaceAll makes the controller
// routable before it starts it, so no start handshake is needed here.
func flapTestManager(tolerance time.Duration) (*Manager, *recordingController) {
m := newTestManager()
m.flapToleranceFor = func(string) time.Duration { return tolerance }
rec := newRecording("recorder")
m.ReplaceAll([]Controller{rec}, config.ControllerMethodConfig{})
return m, rec
}
// TestFlapInhibitionSuppressesBothHalves is the core guarantee: a node that
// returns inside its flap window produces no notification at all, so a flapping
// node does not flood the controllers.
func TestFlapInhibitionSuppressesBothHalves(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("flappy"))
m.NotifyStatusChange(onlineChange("flappy"))
expectNoChange(t, rec, 300*time.Millisecond, "suppressed offline/online pair")
if held := m.heldOffline("flappy"); held != nil {
t.Error("the held offline notification was not released when the node returned")
}
}
// TestFlapInhibitionReportsLateOffline: a node still down when the window
// elapses reports Offline as usual, however late the notification is.
func TestFlapInhibitionReportsLateOffline(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("down"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" || change.UUID != "down" {
t.Errorf("got %+v, want the Offline change for node down", change)
}
if held := m.heldOffline("down"); held != nil {
t.Error("the held notification was still pending after it was dispatched")
}
}
// TestFlapInhibitionReportsRecoveryAfterWindow: once the Offline has gone out,
// the node's eventual return is a genuine recovery and must be reported. This
// is the honest half of the "both or neither, never Offline alone" rule.
func TestFlapInhibitionReportsRecoveryAfterWindow(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("down"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Fatalf("got %+v, want the Offline change", change)
}
m.NotifyStatusChange(onlineChange("down"))
if change := awaitChange(t, rec, "online"); change.Event != "Online" {
t.Errorf("got %+v, want the Online change", change)
}
}
// TestFlapInhibitionDisabledWithoutTolerance guards the previous behavior: with
// no window (the default for every node), an Offline still goes out at once.
func TestFlapInhibitionDisabledWithoutTolerance(t *testing.T) {
m, rec := flapTestManager(0)
m.NotifyStatusChange(offlineChange("plain"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Errorf("got %+v, want the Offline change", change)
}
if held := m.heldOffline("plain"); held != nil {
t.Error("a node without a flap window must not have a held notification")
}
}
// TestFlapInhibitionHonorsEnableStatusNotify pins the gate ordering: a node
// whose notifications are switched off is skipped before any hold is taken, so
// it cannot report a late Offline either.
func TestFlapInhibitionHonorsEnableStatusNotify(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bot_node_config.json")
content := `{"muted": {"enableStatusNotify": false, "flap_tolerant": 600}}`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write node config: %v", err)
}
if err := config.LoadBotNodeConfig(path); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
// Leave nothing behind for the other tests in this package, which reach the
// same package-global node config.
t.Cleanup(func() {
if err := config.LoadBotNodeConfig(filepath.Join(dir, "empty.json")); err != nil {
t.Errorf("reset node config: %v", err)
}
})
// The stub reports a window, so only the allow-list can be responsible for
// the silence.
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("muted"))
expectNoChange(t, rec, 100*time.Millisecond, "offline for a muted node")
if held := m.heldOffline("muted"); held != nil {
t.Error("a muted node must not have its Offline held")
}
}
// TestFlapInhibitionClaimOrder pins both sides of the hold/fire/release race
// deterministically, with a window long enough that the real timer never fires:
// whichever claims the held notification first wins, and the loser sees it gone.
func TestFlapInhibitionClaimOrder(t *testing.T) {
t.Run("release first suppresses the pair", func(t *testing.T) {
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
held := m.heldOffline("node")
if held == nil {
t.Fatal("the offline notification was not held")
}
if !m.releaseHeldOffline("node") {
t.Fatal("releaseHeldOffline reported no pending notification")
}
// The timer goroutine loses the claim and must not dispatch.
m.fireHeldOffline("node", held)
expectNoChange(t, rec, 100*time.Millisecond, "notification after a release")
})
t.Run("fire first reports the offline alone", func(t *testing.T) {
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
held := m.heldOffline("node")
if held == nil {
t.Fatal("the offline notification was not held")
}
m.fireHeldOffline("node", held)
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Errorf("got %+v, want the Offline change", change)
}
if m.releaseHeldOffline("node") {
t.Error("a fired notification must not be claimable again")
}
})
}
// TestStopAllDropsHeldNotifications: a hold must not outlive the controllers it
// was meant for.
func TestStopAllDropsHeldNotifications(t *testing.T) {
m, _ := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
m.StopAll()
if held := m.heldOffline("node"); held != nil {
t.Error("StopAll left a held offline notification armed")
}
}
func TestReplaceAllInstallsAndStarts(t *testing.T) {
m := newTestManager()
alpha, beta := newFake("alpha"), newFake("beta")
m.ReplaceAll([]Controller{alpha, beta}, config.ControllerMethodConfig{})
alpha.waitStarted(t)
beta.waitStarted(t)
if got := m.routedTo("alpha"); got != Controller(alpha) {
t.Errorf("alpha is not routable after ReplaceAll: %v", got)
}
if got := m.routedTo("beta"); got != Controller(beta) {
t.Errorf("beta is not routable after ReplaceAll: %v", got)
}
}
// TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the
// old controllers must be shut down, or a rebuilt onebot or Telegram controller
// would leave its previous connection running.
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
m := newTestManager()
outgoing := newFake("alpha")
m.ReplaceAll([]Controller{outgoing}, config.ControllerMethodConfig{})
outgoing.waitStarted(t)
incoming := newFake("alpha")
m.ReplaceAll([]Controller{incoming}, config.ControllerMethodConfig{})
incoming.waitStarted(t)
if starts, stops := outgoing.lifecycle(); starts != 1 || stops != 1 {
t.Errorf("outgoing controller lifecycle = %d starts / %d stops, want 1/1", starts, stops)
}
if _, stops := incoming.lifecycle(); stops != 0 {
t.Errorf("incoming controller was stopped %d times", stops)
}
if got := m.routedTo("alpha"); got != Controller(incoming) {
t.Error("routing still points at the outgoing controller")
}
}
func TestReplaceAllDropsChannelsLeftOut(t *testing.T) {
m := newTestManager()
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
m.ReplaceAll([]Controller{newFake("beta")}, config.ControllerMethodConfig{})
if got := m.routedTo("alpha"); got != nil {
t.Errorf("a channel missing from the new set is still routable: %v", got)
}
if got := m.routedTo("beta"); got == nil {
t.Error("the surviving channel is not routable")
}
}
func TestNeedsRebuild(t *testing.T) {
m := newTestManager()
base := config.ControllerMethodConfig{
Email: config.EmailConfig{Enabled: true, SMTPPort: 587, To: []string{"a@example.com"}},
}
if !m.NeedsRebuild(base) {
t.Error("a manager with nothing installed must report that a rebuild is needed")
}
m.ReplaceAll(nil, base)
if m.NeedsRebuild(base) {
t.Error("the settings the set was built from must not ask for another rebuild")
}
changed := base
changed.Email.Enabled = false
if !m.NeedsRebuild(changed) {
t.Error("a changed email setting must ask for a rebuild")
}
// The section carries maps and slices, so it is compared by value rather
// than by identity: equal content must not trigger a rebuild.
withHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
}
m.ReplaceAll(nil, withHeaders)
equalHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
}
if m.NeedsRebuild(equalHeaders) {
t.Error("equal header maps must not ask for a rebuild")
}
differentHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "other"}},
}
if !m.NeedsRebuild(differentHeaders) {
t.Error("a changed header must ask for a rebuild")
}
}
// TestReplaceAllDuringNotification drives ReplaceAll while notifications are
// being routed. Run with -race: the swap replaces the map the routing path
// reads, which is what the registry lock exists to make safe.
func TestReplaceAllDuringNotification(t *testing.T) {
m := newTestManager()
m.ReplaceAll([]Controller{newFake("alpha")}, config.ControllerMethodConfig{})
var readers, writers sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 3; i++ {
readers.Add(1)
go func() {
defer readers.Done()
for {
select {
case <-stop:
return
default:
}
m.NotifyStatusChange(node.StatusChange{UUID: "u1", Name: "alpha", Event: "Online"})
_ = m.IsMarkdown("alpha")
}
}()
}
writers.Add(1)
go func() {
defer writers.Done()
for i := 0; i < 20; i++ {
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
}
}()
// Let the writer finish, then release the readers: they only return once
// stop is closed.
writers.Wait()
close(stop)
readers.Wait()
if got := m.routedTo("beta"); got == nil {
t.Error("the last installed set is not routable")
}
}
+9 -22
View File
@@ -2,7 +2,6 @@ package pipes
import (
"fmt"
"net"
gomail "gopkg.in/mail.v2"
@@ -15,32 +14,20 @@ import (
)
// EmailController handles email notifications via SMTP.
//
// cfg is written once, by the constructor, and never again: a controller that
// needs different settings is replaced wholesale by the manager rather than
// reconfigured in place, so the send methods can read it without locking.
type EmailController struct {
cfg config.EmailConfig
}
// NewEmailController creates a new Email controller.
func NewEmailController(cfg config.EmailConfig) *EmailController {
applyEmailProxy(cfg.NetworkUseProxy)
return &EmailController{cfg: cfg}
}
// applyEmailProxy routes SMTP through the HTTP CONNECT proxy, or restores a
// direct dial. gomail exposes the dial path as the package-level
// NetDialTimeout, whose own default is net.DialTimeout, so turning the proxy
// off has to put that back rather than leave the hook in place. There is a
// single global email channel, so setting a package-level hook here is
// unambiguous.
func applyEmailProxy(useProxy bool) {
if useProxy {
if cfg.NetworkUseProxy {
// Route SMTP through the HTTP CONNECT proxy. NetDialTimeout is
// gomail's documented hook for overriding how the SMTP connection is
// dialed. There is a single global email channel, so overriding it
// unconditionally when the flag is set is safe.
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
return
}
gomail.NetDialTimeout = net.DialTimeout
return &EmailController{cfg: cfg}
}
// Name returns the controller name.
@@ -84,7 +71,7 @@ func (e *EmailController) SendStatusChange(change node.StatusChange) error {
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, e.cfg.Markdown)
@@ -98,7 +85,7 @@ func (e *EmailController) SendServerList(onlineServers, offlineServers string) e
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
body := template.Render(cfg.ControllerMessage.ServerList, params, e.cfg.Markdown)
@@ -111,7 +98,7 @@ func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, res
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, e.cfg.Markdown)
@@ -1,54 +0,0 @@
package pipes
import (
"net"
"reflect"
"testing"
gomail "gopkg.in/mail.v2"
"nukumizu-backend/config"
)
// dialerIsDefault reports whether gomail still dials directly. Function values
// are not comparable in Go, so the code pointers are compared instead.
func dialerIsDefault() bool {
return reflect.ValueOf(gomail.NetDialTimeout).Pointer() ==
reflect.ValueOf(net.DialTimeout).Pointer()
}
// TestApplyEmailProxyRestoresDefaultDialer covers the case the constructor used
// to get wrong. gomail exposes its dial hook as a package-level variable with no
// unset, and the code only installed the proxy dialer when the flag was set, so
// turning networkUseProxy back off left SMTP tunnelled through a proxy nobody
// had asked for — with no way to undo it short of a restart.
func TestApplyEmailProxyRestoresDefaultDialer(t *testing.T) {
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
applyEmailProxy(true)
if dialerIsDefault() {
t.Fatal("applyEmailProxy(true) did not install the proxy dialer")
}
applyEmailProxy(false)
if !dialerIsDefault() {
t.Error("applyEmailProxy(false) left the proxy dialer in place")
}
}
// TestNewEmailControllerAppliesProxySetting pins that the dialer follows the
// settings a controller is built with, which is what makes a rebuilt controller
// pick up a changed proxy flag.
func TestNewEmailControllerAppliesProxySetting(t *testing.T) {
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
NewEmailController(config.EmailConfig{NetworkUseProxy: true})
if dialerIsDefault() {
t.Error("a controller built with networkUseProxy: true did not install the proxy dialer")
}
NewEmailController(config.EmailConfig{NetworkUseProxy: false})
if !dialerIsDefault() {
t.Error("a controller rebuilt with networkUseProxy: false left the proxy dialer in place")
}
}
+3 -8
View File
@@ -15,11 +15,6 @@ import (
)
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
//
// cfg and httpClient are written once, by the constructor, and never again: a
// controller that needs different settings is replaced wholesale by the manager
// rather than reconfigured in place, so the send methods can read them without
// locking.
type NtfyController struct {
cfg config.NtfyConfig
httpClient *http.Client
@@ -70,7 +65,7 @@ func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, n.cfg.Markdown)
@@ -84,7 +79,7 @@ func (n *NtfyController) SendServerList(onlineServers, offlineServers string) er
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params, n.cfg.Markdown)
@@ -97,7 +92,7 @@ func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, resu
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, n.cfg.Markdown)
@@ -0,0 +1,321 @@
package qq_napcat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
// APIResponse mirrors NapCat's HTTP API response envelope.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// Client is the HTTP + WebSocket client for a single NapCat instance.
// It both listens for incoming OneBot events over WebSocket and issues
// outbound NapCat API calls over HTTP.
type Client struct {
addr string
port string
token string
useProxy bool
httpClient *http.Client
connMu sync.Mutex
conn *websocket.Conn
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a NapCat client for the given host/port/token. When
// useProxy is set, HTTP API calls and the WebSocket connection are routed
// through the system-wide network proxy.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
httpClient: netproxy.HTTPClient(useProxy, 30*time.Second),
stopCh: make(chan struct{}),
}
}
func (c *Client) baseURL() string {
return fmt.Sprintf("http://%s:%s", c.addr, c.port)
}
// sendRequest performs an HTTP call to NapCat, injecting access_token into POST
// bodies AND setting Authorization: Bearer (compatible with HTTP Server adapters
// created in the NapCat Web UI).
func (c *Client) sendRequest(method, endpoint string, body []byte) ([]byte, error) {
url := c.baseURL() + endpoint
// For POST requests, inject access_token into the JSON body before sending.
var finalBody []byte
if body != nil && c.token != "" {
var bodyMap map[string]interface{}
if err := json.Unmarshal(body, &bodyMap); err == nil {
bodyMap["access_token"] = c.token
finalBody, _ = json.Marshal(bodyMap)
}
}
if finalBody == nil {
finalBody = body
}
var req *http.Request
var err error
if finalBody != nil {
req, err = http.NewRequest(method, url, bytes.NewReader(finalBody))
} else {
req, err = http.NewRequest(method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request to napcat: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read napcat response: %w", err)
}
return respBody, nil
}
// parseResponse unmarshals NapCat's raw response body into an APIResponse.
func (c *Client) parseResponse(respBody []byte, caller string) (*APIResponse, error) {
var napcatResp APIResponse
if err := json.Unmarshal(respBody, &napcatResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal napcat response (%s): %w\n%s", caller, err, string(respBody))
}
return &napcatResp, nil
}
// SendMsg sends a message via NapCat.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var endpoint string
var napcatReq map[string]interface{}
switch targetType {
case "group":
endpoint = "/send_group_msg"
napcatReq = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
endpoint = "/send_private_msg"
napcatReq = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
reqBody, err := json.Marshal(napcatReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
}
respBody, err := c.sendRequest(http.MethodPost, endpoint, reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "SendMsg")
}
// RecallMsg recalls (deletes) a sent message via NapCat.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"message_id": msgID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
}
respBody, err := c.sendRequest(http.MethodPost, "/delete_msg", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "RecallMsg")
}
// GetGroupList retrieves the list of joined groups from NapCat.
func (c *Client) GetGroupList() (*APIResponse, error) {
if config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_group_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupList")
}
// GetGroupInfo retrieves detailed information about a specific group from NapCat.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"group_id": groupID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
}
respBody, err := c.sendRequest(http.MethodPost, "/get_group_info", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupInfo")
}
// GetFriendsList retrieves the friends list from NapCat.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_friend_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetFriendsList")
}
// Listen connects to the NapCat WebSocket server and calls onEvent for every raw
// message. It blocks forever, reconnecting every 5s after a disconnect, until
// Stop is called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[Napcat] WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(5 * time.Second):
}
}
}
// listenOnce connects to the NapCat WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
wsURL := fmt.Sprintf("ws://%s:%s/", c.addr, c.port)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info(fmt.Sprintf("[Napcat] Connected to NapCat WebSocket at %s", wsURL))
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// Stop closes any open WebSocket connection and unblocks the Listen reconnect loop.
func (c *Client) Stop() {
c.stopOnce.Do(func() { close(c.stopCh) })
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
}
@@ -1,4 +1,4 @@
package qq_onebot
package qq_napcat
import (
"encoding/json"
@@ -12,7 +12,7 @@ import (
"nukumizu-backend/postLog"
)
// oneBotEvent mirrors a OneBot 11 event pushed over the OneBot WebSocket.
// oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket.
// Only "message" events are handled; notice/request/meta_event are ignored.
type oneBotEvent struct {
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
@@ -29,46 +29,45 @@ type oneBotEvent struct {
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
}
// QQController handles QQ Bot interactions by connecting directly to OneBot.
// QQController handles QQ Bot interactions by connecting directly to NapCat.
type QQController struct {
cfg config.OneBotConfig
onebotClient *Client
cfg config.QQConfig
napcatClient *Client
}
// NewQQController creates a new QQ (OneBot) controller.
func NewQQController(cfg config.OneBotConfig) *QQController {
// NewQQController creates a new QQ (Napcat) controller.
func NewQQController(cfg config.QQConfig) *QQController {
q := &QQController{cfg: cfg}
if cfg.Enabled {
q.onebotClient = NewClient(cfg.OneBotAddr, cfg.OneBotPort, cfg.OneBotToken, cfg.NetworkUseProxy)
q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy)
}
return q
}
// Name returns the controller name.
func (q *QQController) Name() string {
return "qq_onebot"
return "qq(napcat)"
}
// Start initializes the QQ controller and starts the OneBot WebSocket listeners
// (the event socket and the API socket the client manages internally).
// Start initializes the QQ controller and starts the NapCat WebSocket listener.
func (q *QQController) Start() error {
if !q.cfg.Enabled {
// postLog.Info("QQ (Napcat) controller is disabled")
return nil
}
if q.onebotClient == nil {
postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is nil")
if q.napcatClient == nil {
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil")
return nil
}
postLog.Info("[qq_onebot] QQ (OneBot) controller started, connecting to OneBot WebSocket...")
postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...")
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error("[qq_onebot] OneBot WS listener panic recovered: " + fmt.Sprintf("%v", r))
postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r))
}
}()
q.onebotClient.Listen(q.handleOneBotEvent)
q.napcatClient.Listen(q.handleNapcatEvent)
}()
return nil
@@ -76,10 +75,10 @@ func (q *QQController) Start() error {
// Stop shuts down the QQ controller and its WebSocket listener.
func (q *QQController) Stop() {
if q.onebotClient != nil {
q.onebotClient.Stop()
if q.napcatClient != nil {
q.napcatClient.Stop()
}
postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
postLog.Info("QQ (Napcat) controller stopped")
}
// IsEnabled returns whether the controller is enabled.
@@ -93,43 +92,33 @@ func (q *QQController) IsMarkdown() bool {
return q.cfg.Markdown
}
// handleOneBotEvent processes a raw OneBot event received from the OneBot WebSocket.
func (q *QQController) handleOneBotEvent(raw []byte) {
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
func (q *QQController) handleNapcatEvent(raw []byte) {
var ev oneBotEvent
if err := json.Unmarshal(raw, &ev); err != nil {
postLog.Debug("[qq_onebot] Failed to parse OneBot WS event: " + err.Error())
postLog.Debug("Failed to parse NapCat WS event: " + err.Error())
return
}
// The configuration is read once per event: the debug guards below are
// evaluated several times, and taking them all from one version keeps a
// concurrent reload from splitting them mid-event.
cfg := config.Current()
if cfg == nil {
return
}
debugMode := cfg.System.DebugMode
showAction := debugMode && cfg.Debug.ShowOneBotAction
if debugMode && cfg.Debug.ShowOneBotMsg {
postLog.Debug("[qq_onebot] OneBot WS event received: " + string(raw))
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatMsg {
postLog.Debug("Napcat WS event received: " + string(raw))
}
// Only handle message events; ignore notice/request/meta_event.
if ev.PostType != "message" {
if showAction {
postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("Ignoring Napcat WS event: " + string(raw))
}
return
}
// Ignore messages the bot itself sent (echo prevention).
if q.isSelfMessage(ev) && !debugMode {
if q.isSelfMessage(ev) && !config.C_globalConfig.System.DebugMode {
return
}
if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
if showAction {
postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
if q.isSelfMessage(ev) && config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.NapcatIgnoreSelfMsg {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
}
return
}
@@ -149,20 +138,20 @@ func (q *QQController) handleOneBotEvent(raw []byte) {
response := q.processCommand(cmd)
if response == "" {
if showAction {
postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("Napcat WS command discarded: " + string(raw))
}
return
}
// Reply in the same chat.
if ev.MessageType == "private" {
if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat private reply: " + err.Error())
}
} else {
if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat group reply: " + err.Error())
}
}
}
@@ -171,7 +160,6 @@ func (q *QQController) handleOneBotEvent(raw []byte) {
// complete command to the unified processor. It returns the response text to
// reply with; an empty response means the message was discarded.
func (q *QQController) processCommand(cmd controller.Command) string {
cfg := config.Current()
text := cmd.RawText
// In "at" listen mode, require an @mention of the bot and strip it before
@@ -180,8 +168,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
if q.cfg.ListenMethod == "at" {
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
if !strings.Contains(text, atMention) {
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
}
return "" // Not mentioned, ignore.
}
@@ -202,14 +190,13 @@ func (q *QQController) processCommand(cmd controller.Command) string {
// Hand the complete command to the unified processor, which checks group
// vs private, trusted groups, admin permissions, and executes it.
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
postLog.Debug(fmt.Sprintf("[qq_onebot] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
}
if err != nil {
postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
postLog.Error("Napcat command processing failed: " + err.Error())
return ""
}
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
return response
}
@@ -226,7 +213,7 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool {
// adminIDs returns the QQ admin IDs from bot_user_config.json.
func (q *QQController) adminIDs() []string {
if c := config.BotUsers(); c != nil {
if c := config.C_botUserConfig; c != nil {
return c.QQ.Admins.IDs()
}
return nil
@@ -234,7 +221,7 @@ func (q *QQController) adminIDs() []string {
// trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json.
func (q *QQController) trustedGroupIDs() []string {
if c := config.BotUsers(); c != nil {
if c := config.C_botUserConfig; c != nil {
return c.QQ.TrustedGroups.IDs()
}
return nil
@@ -250,7 +237,7 @@ func (q *QQController) SendMessage(message controller.Message) error {
}
// Only notify trusted groups and admins whose options allow this message type.
if uc := config.BotUsers(); uc != nil {
if uc := config.C_botUserConfig; uc != nil {
for groupID, opts := range uc.QQ.TrustedGroups {
if !controller.MemberReceives(opts, message.Type) {
continue
@@ -273,12 +260,12 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error {
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, q.cfg.Markdown)
// Only notify trusted groups and admins whose event_status_notify is true.
if uc := config.BotUsers(); uc != nil {
if uc := config.C_botUserConfig; uc != nil {
for groupID, opts := range uc.QQ.TrustedGroups {
if !opts.EventStatusNotify {
continue
@@ -302,7 +289,7 @@ func (q *QQController) SendServerList(onlineServers, offlineServers string) erro
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown)
@@ -318,7 +305,7 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown)
@@ -343,35 +330,35 @@ func (q *QQController) SendAlert(alert controller.Alert) error {
}
func (q *QQController) sendGroupMessage(groupID string, message string) {
if q.onebotClient == nil {
postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
if q.napcatClient == nil {
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
return
}
var groupIDInt int64
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
postLog.Warning("[qq_onebot] Invalid QQ group ID: " + groupID)
postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID))
return
}
if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err))
}
}
func (q *QQController) sendPrivateMessage(userID string, message string) {
if q.onebotClient == nil {
postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
if q.napcatClient == nil {
postLog.Warning("Cannot send QQ private message: NapCat client not initialized")
return
}
var userIDInt int64
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
postLog.Warning("[qq_onebot] Invalid QQ user ID: " + userID)
postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID))
return
}
if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err))
}
}
@@ -1,562 +0,0 @@
package qq_onebot
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
const (
// onebotReconnectDelay is the pause between reconnect attempts, shared by the
// event socket and the API socket.
onebotReconnectDelay = 5 * time.Second
// onebotAPITimeout bounds one callAPI response wait. It matches the timeout the
// removed HTTP client (netproxy.HTTPClient(useProxy, 30s)) imposed, so callers
// see the same worst-case latency they always did.
onebotAPITimeout = 30 * time.Second
// onebotConnectWait bounds how long callAPI waits for the API socket to come up
// when it is not connected yet. It is deliberately short: the startup bot
// initialization message is sent on the main goroutine, and a down OneBot
// server must not stall HTTP-server startup for the full API timeout.
onebotConnectWait = 5 * time.Second
// onebotWriteTimeout bounds a single socket write so a wedged peer cannot block
// the caller (and, through writeMu, every other writer) indefinitely.
onebotWriteTimeout = 10 * time.Second
)
// actionLogEnabled reports whether the OneBot API methods echo each action they
// send, per the showOneBotAction toggle. Unlike the WebSocket message path in
// qq.go it deliberately does not also require debugMode: these methods have
// always logged on this toggle alone, and preserving that is intentional.
func actionLogEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowOneBotAction
}
// APIResponse mirrors OneBot's API response envelope. Echo is the correlation id
// the client sent, echoed back by the server; callAPI uses it internally to match
// a response to its request. Data stays raw so callers decode get_group_list /
// get_friend_list arrays themselves.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Echo json.RawMessage `json:"echo"`
}
// apiRequest is one OneBot API call written to the API socket. A struct rather
// than a map guarantees Echo is encoded as a JSON string, which is what the
// server sends back verbatim.
type apiRequest struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
Echo string `json:"echo"`
}
// apiResult carries one decoded API response (or a transport error) from the API
// read loop back to the callAPI invocation waiting on its echo.
type apiResult struct {
resp *APIResponse
err error
}
// Client is the WebSocket client for a single OneBot instance. It uses two
// forward-WebSocket connections, per the OneBot 11 split between events and API
// calls:
//
// - the event socket ("/") only reads: one goroutine feeds every frame to
// onEvent. Nothing is ever written to it.
// - the API socket ("/api") only issues actions: callAPI writes a
// {"action","params","echo"} frame there, and its dedicated read loop matches
// the reply by echo.
//
// They must be separate: qq.go's event handler replies with SendMsg from inside
// the event read loop, so reading API responses on that same socket would
// deadlock (the loop would be blocked in the handler, waiting on a response only
// it could deliver).
type Client struct {
addr string
port string
token string
useProxy bool
// Event socket ("/"): one reader (listenOnce), closed only by Stop or its own
// cleanup, never written to.
connMu sync.Mutex
conn *websocket.Conn
// API socket ("/api"). apiMu guards the pointer, the epoch channel and the
// pending table; writeMu serializes the actual socket writes, so apiMu is
// never held across I/O (which would stall response dispatch behind a slow
// write).
apiMu sync.Mutex
apiConn *websocket.Conn
apiChanged chan struct{} // closed and replaced on every connect/disconnect
writeMu sync.Mutex
pending map[string]chan apiResult
echoSeq atomic.Uint64
stopped bool
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a OneBot client for the given host/port/token. When useProxy
// is set, both WebSocket connections are routed through the system-wide network
// proxy. It performs no I/O; Listen starts the sockets.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
apiChanged: make(chan struct{}),
pending: make(map[string]chan apiResult),
stopCh: make(chan struct{}),
}
}
// dialWS opens one forward-WebSocket connection to the given path. The token is
// passed both as an access_token query parameter and as an Authorization: Bearer
// header (forward adapters check one or the other), and the dial goes through the
// system proxy when useProxy is set.
func (c *Client) dialWS(path string) (*websocket.Conn, error) {
wsURL := fmt.Sprintf("ws://%s:%s%s", c.addr, c.port, path)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
return nil, fmt.Errorf("failed to connect to onebot websocket %s: %w", path, err)
}
return conn, nil
}
// SendMsg sends a message via OneBot.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var action string
var params map[string]interface{}
switch targetType {
case "group":
action = "send_group_msg"
params = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
action = "send_private_msg"
params = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] SendMsg -> %s (%s): %s", action, targetType, message))
}
return c.callAPI(action, params)
}
// RecallMsg recalls (deletes) a sent message via OneBot.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] RecallMsg -> delete_msg: %d", msgID))
}
return c.callAPI("delete_msg", map[string]interface{}{"message_id": msgID})
}
// GetGroupList retrieves the list of joined groups from OneBot.
func (c *Client) GetGroupList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetGroupList -> get_group_list")
}
return c.callAPI("get_group_list", nil)
}
// GetGroupInfo retrieves detailed information about a specific group from OneBot.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] GetGroupInfo -> get_group_info: %d", groupID))
}
return c.callAPI("get_group_info", map[string]interface{}{"group_id": groupID})
}
// GetFriendsList retrieves the friends list from OneBot.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetFriendsList -> get_friend_list")
}
return c.callAPI("get_friend_list", nil)
}
// callAPI issues one OneBot action over the API socket and waits for its reply.
//
// It returns a Go error only for transport failures (not connected, write error,
// disconnect, timeout, Stop). An OneBot-level failure (status "failed", retcode
// 1xxx) is returned as an ordinary (*APIResponse, nil), matching the behavior of
// the HTTP client this replaces.
func (c *Client) callAPI(action string, params map[string]interface{}) (*APIResponse, error) {
// The spec allows omitting params entirely, but some adapters reject a null
// value, so a bodyless action still sends an empty object.
if params == nil {
params = map[string]interface{}{}
}
echo := strconv.FormatUint(c.echoSeq.Add(1), 10)
payload, err := json.Marshal(apiRequest{Action: action, Params: params, Echo: echo})
if err != nil {
return nil, fmt.Errorf("failed to marshal onebot request: %w", err)
}
// Buffered cap 1: the API read loop and failPending can each deliver at most
// once, and delivering must never block the read loop.
ch := make(chan apiResult, 1)
c.apiMu.Lock()
if c.stopped {
c.apiMu.Unlock()
return nil, fmt.Errorf("onebot client stopped")
}
c.pending[echo] = ch
c.apiMu.Unlock()
// Deregister on every exit path. dispatchAPI deletes the entry too, so this is
// a no-op when the response already arrived.
defer func() {
c.apiMu.Lock()
delete(c.pending, echo)
c.apiMu.Unlock()
}()
conn, err := c.waitForAPIConn()
if err != nil {
return nil, err
}
if err := c.writeAPI(conn, payload); err != nil {
return nil, fmt.Errorf("failed to send onebot request: %w", err)
}
timer := time.NewTimer(onebotAPITimeout)
defer timer.Stop()
select {
case res := <-ch:
return res.resp, res.err
case <-timer.C:
return nil, fmt.Errorf("onebot API %s timed out after %s", action, onebotAPITimeout)
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
// waitForAPIConn returns the live API connection, waiting (bounded by
// onebotConnectWait, or until Stop) for apiLoop to establish one when it is not
// connected yet. It re-checks the state on every apiChanged transition, so a
// closed channel left over from a previous connection cannot fool it.
func (c *Client) waitForAPIConn() (*websocket.Conn, error) {
timer := time.NewTimer(onebotConnectWait)
defer timer.Stop()
for {
c.apiMu.Lock()
conn := c.apiConn
changed := c.apiChanged
stopped := c.stopped
c.apiMu.Unlock()
if conn != nil {
return conn, nil
}
if stopped {
return nil, fmt.Errorf("onebot client stopped")
}
select {
case <-changed:
// Connection state changed; re-check.
case <-timer.C:
return nil, fmt.Errorf("onebot API WebSocket not connected")
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
}
// writeAPI serializes one request frame onto conn under writeMu, with a write
// deadline. gorilla/websocket panics on concurrent writes, and both the event
// handler and every SendMessage path call callAPI, so concurrency is the norm.
func (c *Client) writeAPI(conn *websocket.Conn, payload []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(onebotWriteTimeout)); err != nil {
return err
}
return conn.WriteMessage(websocket.TextMessage, payload)
}
// Listen connects the event WebSocket and calls onEvent for every raw message. It
// starts the API socket loop as well, since NewClient is side-effect-free and
// Listen is the single entry point the controller drives. It blocks forever,
// reconnecting every onebotReconnectDelay after a disconnect, until Stop is
// called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
go c.apiLoop()
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[qq_onebot] Event WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// listenOnce connects to the OneBot event WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
conn, err := c.dialWS("/")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot event WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info("[qq_onebot] Connected to OneBot event WebSocket")
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Event WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// apiLoop owns the API socket: it keeps a connection to "/api" up, dispatching
// any reply its read loop receives and reconnecting every onebotReconnectDelay.
// It exits on Stop.
func (c *Client) apiLoop() {
for {
select {
case <-c.stopCh:
return
default:
}
c.runAPIConnection()
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// runAPIConnection dials "/api", publishes the connection to waiters, and reads
// response frames until the connection drops or Stop is called. On the way out it
// marks the socket down (waking waiters and failing in-flight calls) so a caller
// does not wait the full timeout for a reply that can no longer arrive.
func (c *Client) runAPIConnection() {
conn, err := c.dialWS("/api")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot API WebSocket: %v", err))
return
}
c.apiMu.Lock()
c.apiConn = conn
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
postLog.Info("[qq_onebot] Connected to OneBot API WebSocket")
c.readAPILoop(conn)
c.apiMu.Lock()
if c.apiConn == conn {
c.apiConn = nil
}
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
conn.Close()
c.failPending(fmt.Errorf("onebot API WebSocket disconnected"))
}
// readAPILoop reads response frames from the API socket until the connection
// drops or Stop is called. It is the only reader of that connection.
func (c *Client) readAPILoop(conn *websocket.Conn) {
for {
select {
case <-c.stopCh:
return
default:
}
_, data, err := conn.ReadMessage()
if err != nil {
select {
case <-c.stopCh:
// Stopping: the read error is just the closed socket.
default:
postLog.Error(fmt.Sprintf("[qq_onebot] API WebSocket read error: %v", err))
}
return
}
c.dispatchAPI(data)
}
}
// dispatchAPI decodes one inbound frame on the API socket. A frame with no usable
// echo is not an API response and is ignored (the event socket is the only place
// events are expected); otherwise the pending channel registered for that echo
// receives the envelope and is removed, both under apiMu.
func (c *Client) dispatchAPI(raw []byte) {
var resp APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
postLog.Debug("[qq_onebot] Failed to parse OneBot API response: " + err.Error())
return
}
key, ok := echoKey(resp.Echo)
if !ok {
postLog.Debug("[qq_onebot] Ignoring non-response frame on OneBot API WebSocket: " + string(raw))
return
}
c.apiMu.Lock()
ch := c.pending[key]
delete(c.pending, key)
c.apiMu.Unlock()
if ch != nil {
ch <- apiResult{resp: &resp}
}
}
// echoKey normalizes an echo value to its pending-map key. An absent or null echo
// yields ok=false, i.e. the frame is not an API response. A JSON string is
// unquoted; any other type falls back to its verbatim bytes so an adapter that
// returns the id unquoted still matches.
func echoKey(raw json.RawMessage) (string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, true
}
return string(raw), true
}
// failPending delivers err to every call waiting on a response and clears the
// table, so a dropped API socket unblocks its callers immediately. Called on
// disconnect and by Stop; safe to call repeatedly.
func (c *Client) failPending(err error) {
c.apiMu.Lock()
pending := c.pending
c.pending = make(map[string]chan apiResult)
c.apiMu.Unlock()
for _, ch := range pending {
// Buffered cap 1, and each entry is owned exactly once, so this never blocks.
ch <- apiResult{err: err}
}
}
// Stop closes both WebSocket connections and unblocks the event reconnect loop,
// the API loop and every in-flight callAPI. It is idempotent and safe to call
// even when Listen was never started.
func (c *Client) Stop() {
c.stopOnce.Do(func() {
close(c.stopCh)
c.apiMu.Lock()
c.stopped = true
apiConn := c.apiConn
c.apiMu.Unlock()
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
if apiConn != nil {
apiConn.Close()
}
c.failPending(fmt.Errorf("onebot client stopped"))
})
}
@@ -1,235 +0,0 @@
package qq_onebot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeOneBot is a minimal OneBot 11 forward-WebSocket server. "/api" answers
// every action call by echoing its echo with a fixed ok response; "/" accepts the
// event socket and hands it to the test so events can be pushed on demand.
type fakeOneBot struct {
upgrader websocket.Upgrader
server *httptest.Server
apiRequests chan fakeAPIRequest
eventConns chan *websocket.Conn
}
type fakeAPIRequest struct {
Action string `json:"action"`
Params json.RawMessage `json:"params"`
Echo json.RawMessage `json:"echo"`
}
func newFakeOneBot(t *testing.T) *fakeOneBot {
t.Helper()
f := &fakeOneBot{
apiRequests: make(chan fakeAPIRequest, 16),
eventConns: make(chan *websocket.Conn, 16),
}
mux := http.NewServeMux()
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var req fakeAPIRequest
if err := json.Unmarshal(data, &req); err != nil {
continue
}
f.apiRequests <- req
resp, _ := json.Marshal(map[string]json.RawMessage{
"status": json.RawMessage(`"ok"`),
"retcode": json.RawMessage(`0`),
"data": json.RawMessage(`{"message_id":123}`),
"echo": req.Echo,
})
if err := conn.WriteMessage(websocket.TextMessage, resp); err != nil {
return
}
}
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
f.eventConns <- conn
// Keep the event socket open until the client goes away.
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
// client returns a Client pointed at this fake server with the proxy disabled.
func (f *fakeOneBot) client(t *testing.T) *Client {
t.Helper()
u, err := url.Parse(f.server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
return NewClient(u.Hostname(), u.Port(), "", false)
}
// eventConn waits for the event socket to be established and returns it.
func (f *fakeOneBot) eventConn(t *testing.T) *websocket.Conn {
t.Helper()
select {
case conn := <-f.eventConns:
return conn
case <-time.After(3 * time.Second):
t.Fatal("event WebSocket never connected")
return nil
}
}
// TestSendMsgOverWebSocket checks an API call is issued over the "/api" socket
// and its response is decoded.
func TestSendMsgOverWebSocket(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
resp, err := c.SendMsg("group", 12345, "hello", false, 0)
if err != nil {
t.Fatalf("SendMsg: %v", err)
}
if resp.Status != "ok" || resp.RetCode != 0 {
t.Fatalf("unexpected response: %+v", resp)
}
var data struct {
MessageID int64 `json:"message_id"`
}
if err := json.Unmarshal(resp.Data, &data); err != nil {
t.Fatalf("decode data: %v", err)
}
if data.MessageID != 123 {
t.Fatalf("message_id = %d, want 123", data.MessageID)
}
req := <-f.apiRequests
if req.Action != "send_group_msg" {
t.Fatalf("action = %q, want send_group_msg", req.Action)
}
var params map[string]any
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params["group_id"].(float64) != 12345 || params["message"].(string) != "hello" {
t.Fatalf("params = %v", params)
}
var echo string
if err := json.Unmarshal(req.Echo, &echo); err != nil {
t.Fatalf("echo is not a JSON string: %s", req.Echo)
}
}
// TestBodylessActionSendsEmptyParams checks actions without arguments still send
// a params object (never null), which some adapters reject.
func TestBodylessActionSendsEmptyParams(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
if _, err := c.GetGroupList(); err != nil {
t.Fatalf("GetGroupList: %v", err)
}
req := <-f.apiRequests
if req.Action != "get_group_list" {
t.Fatalf("action = %q, want get_group_list", req.Action)
}
if string(req.Params) != "{}" {
t.Fatalf("params = %s, want {}", req.Params)
}
}
// TestSendMsgFromEventCallbackDoesNotDeadlock is the regression test for the
// reason the client uses two sockets: the event handler replies with SendMsg from
// inside the event read loop, so a response read on that same connection would
// never arrive.
func TestSendMsgFromEventCallbackDoesNotDeadlock(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
type result struct {
resp *APIResponse
err error
}
done := make(chan result, 1)
go c.Listen(func(raw []byte) {
resp, err := c.SendMsg("group", 1, "pong", false, 0)
done <- result{resp, err}
})
defer c.Stop()
evConn := f.eventConn(t)
event := []byte(`{"post_type":"message","message_type":"group","group_id":1,"user_id":2,"raw_message":"/ping","self_id":9}`)
if err := evConn.WriteMessage(websocket.TextMessage, event); err != nil {
t.Fatalf("write event: %v", err)
}
select {
case r := <-done:
if r.err != nil {
t.Fatalf("SendMsg from event callback: %v", r.err)
}
if r.resp.Status != "ok" {
t.Fatalf("unexpected response: %+v", r.resp)
}
case <-time.After(5 * time.Second):
t.Fatal("deadlock: SendMsg did not return from within the event callback")
}
}
// TestEchoKey covers the response-matcher: an absent or null echo is not a
// response, and both string and non-string echoes normalize to a key.
func TestEchoKey(t *testing.T) {
cases := []struct {
in string
key string
ok bool
}{
{`"42"`, "42", true},
{`42`, "42", true},
{`null`, "", false},
{``, "", false},
}
for _, tc := range cases {
key, ok := echoKey(json.RawMessage(tc.in))
if key != tc.key || ok != tc.ok {
t.Errorf("echoKey(%q) = (%q, %v), want (%q, %v)", tc.in, key, ok, tc.key, tc.ok)
}
}
}
@@ -148,7 +148,7 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
}
msg := update.Message
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTelegramMsg {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTelegramMsg {
raw, _ := json.Marshal(update)
postLog.Debug("Telegram update received: " + string(raw))
}
@@ -232,7 +232,7 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
// Hand the complete command to the unified processor, which checks group
// vs private, trusted groups, admin permissions, and executes it.
response, err := controller.GetManager().Trigger(parsed, t.trustedGroupIDs(), t.resolvedAdminList(), t.cfg.ListenMethod)
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
postLog.Debug(fmt.Sprintf("[telegram] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
}
if err != nil {
@@ -252,7 +252,7 @@ func (t *TelegramController) SendMessage(message controller.Message) error {
}
// Only notify trusted groups and admins whose options allow this message type.
if uc := config.BotUsers(); uc != nil {
if uc := config.C_botUserConfig; uc != nil {
for groupID, opts := range uc.Telegram.TrustedGroups {
if !controller.MemberReceives(opts, message.Type) {
continue
@@ -275,12 +275,12 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, t.cfg.Markdown)
// Only notify trusted groups and admins whose event_status_notify is true.
if uc := config.BotUsers(); uc != nil {
if uc := config.C_botUserConfig; uc != nil {
for groupID, opts := range uc.Telegram.TrustedGroups {
if !opts.EventStatusNotify {
continue
@@ -303,7 +303,7 @@ func (t *TelegramController) SendServerList(onlineServers, offlineServers string
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params, t.cfg.Markdown)
@@ -317,7 +317,7 @@ func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command,
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, t.cfg.Markdown)
@@ -395,7 +395,7 @@ func (t *TelegramController) resolveUsername(username string) (int64, bool) {
// adminIDs returns the Telegram admin entries (numeric user ID or @username)
// from bot_user_config.json.
func (t *TelegramController) adminIDs() []string {
if c := config.BotUsers(); c != nil {
if c := config.C_botUserConfig; c != nil {
return c.Telegram.Admins.IDs()
}
return nil
@@ -403,7 +403,7 @@ func (t *TelegramController) adminIDs() []string {
// trustedGroupIDs returns the Telegram trusted group IDs from bot_user_config.json.
func (t *TelegramController) trustedGroupIDs() []string {
if c := config.BotUsers(); c != nil {
if c := config.C_botUserConfig; c != nil {
return c.Telegram.TrustedGroups.IDs()
}
return nil
+3 -8
View File
@@ -16,11 +16,6 @@ import (
)
// WebhookController handles notifications via generic HTTP webhooks.
//
// cfg and httpClient are written once, by the constructor, and never again: a
// controller that needs different settings is replaced wholesale by the manager
// rather than reconfigured in place, so the send methods can read them without
// locking.
type WebhookController struct {
cfg config.WebhookConfig
httpClient *http.Client
@@ -71,7 +66,7 @@ func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, w.cfg.Markdown)
@@ -92,7 +87,7 @@ func (w *WebhookController) SendServerList(onlineServers, offlineServers string)
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params, w.cfg.Markdown)
@@ -113,7 +108,7 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
return nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, w.cfg.Markdown)
+4 -4
View File
@@ -22,13 +22,13 @@ func commandMarkdown(cmd Command) bool {
}
func handleHelp(cmd Command) (string, error) {
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams()
return template.Render(cfg.ControllerMessage.BotHelp, params, commandMarkdown(cmd)), nil
}
func handleList(cmd Command) (string, error) {
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
return template.Render(cfg.ControllerMessage.ServerList, params, commandMarkdown(cmd)), nil
}
@@ -139,7 +139,7 @@ func handleRun(cmd Command) (string, error) {
return fmt.Sprintf("Error getting results: %v", err), nil
}
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, commandMarkdown(cmd)), nil
}
@@ -189,7 +189,7 @@ func handleInfo(cmd Command) (string, error) {
}
func telegram_handleStart(cmd Command) (string, error) {
cfg := config.Current()
cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams()
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, commandMarkdown(cmd)), nil
}
-5
View File
@@ -3,8 +3,6 @@ package controller
import (
"fmt"
"strings"
"nukumizu-backend/postLog"
)
// Trigger validates an incoming command against the pipe's authorization
@@ -18,11 +16,9 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
// Check whether the command arrived in a group or a private chat.
if cmd.ChatType == "group" {
// Group commands are only honored from trusted groups, otherwise discard.
// postLog.Debug(fmt.Sprintf("trustedGroups: %v", trustedGroups))
if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
return "", nil
}
postLog.Debug("isTrustedGroup")
}
// Commands that require special permission must be sent by an admin.
@@ -41,7 +37,6 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
if response == "" && listenMethod == "at" {
return "Unknown command: /" + cmd.Command, nil
}
// postLog.Debug("[controller] Trigger.resp: " + response)
return response, nil
}
+58 -184
View File
@@ -3,7 +3,6 @@ package komari
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -19,14 +18,6 @@ import (
"nukumizu-backend/postLog"
)
// taskEchoEnabled reports whether Komari task progress should be echoed to the
// log: debug mode plus the showKomariTaskEcho toggle. The configuration is read
// once per call so both flags come from the same reload.
func taskEchoEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowKomariTaskEcho
}
// NodeInfo represents a single node as returned by Komari's
// common:getNodes RPC2 method.
type NodeInfo struct {
@@ -45,8 +36,8 @@ type NodeInfo struct {
SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"`
Weight float64 `json:"weight"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"`
@@ -74,32 +65,32 @@ type TaskResult struct {
// ClientInfo holds client information returned with task results.
type ClientInfo struct {
Name string `json:"name"`
CPUName string `json:"cpu_name"`
Virtualization string `json:"virtualization"`
Arch string `json:"arch"`
CPUCores int `json:"cpu_cores"`
CPUPhysicalCores int `json:"cpu_physical_cores"`
OS string `json:"os"`
KernelVersion string `json:"kernel_version"`
GPUName string `json:"gpu_name"`
Region string `json:"region"`
MemTotal int64 `json:"mem_total"`
SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"`
Weight int `json:"weight"`
Price int `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"`
Currency string `json:"currency"`
Name string `json:"name"`
CPUName string `json:"cpu_name"`
Virtualization string `json:"virtualization"`
Arch string `json:"arch"`
CPUCores int `json:"cpu_cores"`
CPUPhysicalCores int `json:"cpu_physical_cores"`
OS string `json:"os"`
KernelVersion string `json:"kernel_version"`
GPUName string `json:"gpu_name"`
Region string `json:"region"`
MemTotal int64 `json:"mem_total"`
SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"`
Weight int `json:"weight"`
Price int `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"`
Currency string `json:"currency"`
ExpiredAt *string `json:"expired_at"`
Group string `json:"group"`
Tags string `json:"tags"`
Hidden bool `json:"hidden"`
TrafficLimit int64 `json:"traffic_limit"`
TrafficLimitType string `json:"traffic_limit_type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Group string `json:"group"`
Tags string `json:"tags"`
Hidden bool `json:"hidden"`
TrafficLimit int64 `json:"traffic_limit"`
TrafficLimitType string `json:"traffic_limit_type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// KomariResponse is the standard response envelope from Komari API.
@@ -137,120 +128,27 @@ func NewClient(baseURL string) *Client {
}
}
// retryMaxAttempts bounds how many times a request is tried before giving up.
const retryMaxAttempts = 6
// retryBackoff is the linear backoff step between attempts: the n-th retry
// waits n*retryBackoff.
const retryBackoff = 500 * time.Millisecond
// retryableError marks a failure worth retrying: transport errors, rate
// limiting and 5xx responses. Rejected credentials, 4xx responses and
// malformed payloads are not retryable, so callers fail fast instead of
// hammering Komari (which could trip account lockout or rate limits).
type retryableError struct {
err error
}
func (e *retryableError) Error() string { return e.err.Error() }
func (e *retryableError) Unwrap() error { return e.err }
func retryableErrorf(format string, args ...any) error {
return &retryableError{err: fmt.Errorf(format, args...)}
}
// isRetryableError reports whether err was produced by a failure that another
// attempt may resolve.
func isRetryableError(err error) bool {
var retryable *retryableError
return errors.As(err, &retryable)
}
// doWithRetry runs fn until it succeeds, fails with a non-retryable error, or
// exhausts retryMaxAttempts attempts. what names the operation in logs and in
// the returned error.
func doWithRetry(what string, fn func() error) error {
var lastErr error
for attempt := 1; ; attempt++ {
if err := fn(); err != nil {
lastErr = err
if !isRetryableError(err) || attempt == retryMaxAttempts {
return fmt.Errorf("%s failed after %d attempt(s): %w", what, attempt, lastErr)
}
postLog.Warning(fmt.Sprintf("%s attempt %d/%d failed, retrying: %v", what, attempt, retryMaxAttempts, err))
time.Sleep(time.Duration(attempt) * retryBackoff)
continue
}
return nil
}
}
// truncateBody keeps error messages readable when a proxy or gateway answers
// with an unexpected (possibly HTML) payload.
func truncateBody(body []byte) string {
const limit = 512
if len(body) > limit {
return string(body[:limit]) + "..."
}
return strings.TrimSpace(string(body))
}
// Login authenticates to Komari and stores the session token cookie. Transport
// errors, 429 and 5xx responses are retried with linear backoff; credential or
// protocol failures return immediately.
// Login authenticates to Komari and stores the session token cookie.
func (c *Client) Login(username, password string) error {
postLog.Info("Logging into Komari Dashboard at " + c.baseURL)
err := doWithRetry("komari login", func() error {
return c.loginOnce(username, password)
})
if err != nil {
// Drop any stale token so callers cannot keep using a dead session.
c.mu.Lock()
c.sessionToken = ""
c.mu.Unlock()
return err
}
postLog.Info("Successfully logged into Komari Dashboard")
return nil
}
// loginOnce performs a single login round trip. The response body is closed
// before returning so retries never accumulate open connections.
func (c *Client) loginOnce(username, password string) error {
body := map[string]string{
"username": username,
"password": password,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal komari login request: %w", err)
}
bodyJSON, _ := json.Marshal(body)
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
if err != nil {
return retryableErrorf("komari login request failed: %w", err)
return fmt.Errorf("komari login request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return retryableErrorf("failed to read komari login response: %w", err)
}
switch {
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
return retryableErrorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
case resp.StatusCode != http.StatusOK:
return fmt.Errorf("komari login request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
}
var kr KomariResponse
if err := json.Unmarshal(respBody, &kr); err != nil {
if config.IsDebugMode() {
return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, truncateBody(respBody))
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
if config.C_globalConfig.System.DebugMode {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, respBody)
}
return fmt.Errorf("failed to parse komari login response: %w", err)
}
@@ -265,6 +163,7 @@ func (c *Client) loginOnce(username, password string) error {
c.mu.Lock()
c.sessionToken = cookie.Value
c.mu.Unlock()
postLog.Info("Successfully logged into Komari Dashboard")
return nil
}
}
@@ -281,40 +180,7 @@ func (c *Client) GetSessionToken() string {
}
// FetchNodes retrieves all nodes from Komari via the JSON-RPC2 endpoint.
// Transport errors, 429 and 5xx responses are retried with linear backoff;
// RPC-level errors and malformed payloads fail immediately.
func (c *Client) FetchNodes() ([]NodeInfo, error) {
var nodes []NodeInfo
err := doWithRetry("komari nodes fetch", func() error {
var err error
nodes, err = c.fetchNodesOnce()
return err
})
if err != nil {
return nil, err
}
// 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.Debug(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
return nodes, nil
}
// fetchNodesOnce performs a single nodes RPC round trip. The response body is
// closed before returning so retries never accumulate open connections.
func (c *Client) fetchNodesOnce() ([]NodeInfo, error) {
payload, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"method": "common:getNodes",
@@ -333,24 +199,16 @@ func (c *Client) fetchNodesOnce() ([]NodeInfo, error) {
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, retryableErrorf("komari nodes request failed: %w", err)
return nil, fmt.Errorf("komari nodes request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, retryableErrorf("failed to read komari nodes response: %w", err)
}
switch {
case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
return nil, retryableErrorf("komari nodes request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("komari nodes request failed: unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("komari nodes request failed: unexpected status code %d", resp.StatusCode)
}
var rpcResp jsonRpcResponse
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to parse komari nodes RPC response: %w", err)
}
@@ -367,6 +225,22 @@ func (c *Client) fetchNodesOnce() ([]NodeInfo, error) {
for _, n := range nodeMap {
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)))
return nodes, nil
}
@@ -458,7 +332,7 @@ func (c *Client) ExecTask(uuids []string, command string) (string, error) {
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
}
if taskEchoEnabled() {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
postLog.Debug(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
}
return result.TaskID, nil
@@ -501,7 +375,7 @@ func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) {
// PollTaskResult polls for task results every 1 second until all results are
// available or 60 seconds have elapsed.
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
if taskEchoEnabled() {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
postLog.Debug(fmt.Sprintf("Polling for Komari task %s results...", taskID))
}
@@ -519,7 +393,7 @@ func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
return nil, err
}
if done {
if taskEchoEnabled() {
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
}
return results, nil
+1 -4
View File
@@ -409,10 +409,7 @@ func GetWSClient() *WSClient {
// LoginAndStart performs the Komari login and returns an error if it fails.
func LoginAndStart() error {
cfg := config.Current()
if cfg == nil {
return fmt.Errorf("configuration not loaded")
}
cfg := config.C_globalConfig
client := GetClient()
if client == nil {
return fmt.Errorf("komari client not initialized")
+11 -29
View File
@@ -2,10 +2,6 @@
// network proxy configured in the system config. Each caller decides whether
// to use the proxy by passing its own useProxy flag (the per-channel
// networkUseProxy setting), so proxying is opt-in per channel.
//
// The opt-in is captured when a client is built, but the proxy address is not:
// it is read again on every request and every dial, so editing
// system.networkProxy takes effect on clients that already exist.
package netproxy
import (
@@ -24,11 +20,7 @@ import (
// proxyURL returns the system-wide network proxy URL, or nil when none is
// configured. A missing scheme is normalized to http:// for convenience.
func proxyURL() *url.URL {
cfg := config.Current()
if cfg == nil {
return nil
}
raw := cfg.System.NetworkProxy
raw := config.C_globalConfig.System.NetworkProxy
if raw == "" {
return nil
}
@@ -43,23 +35,19 @@ func proxyURL() *url.URL {
}
// ProxyFunc returns a transport proxy function that routes requests through
// the configured network proxy when enabled, and nil when the caller opts out
// of proxying entirely. The returned function is compatible with both
// http.Transport.Proxy and websocket.Dialer.Proxy.
//
// The proxy address is resolved on every call rather than once here, so a
// settings update that changes system.networkProxy reaches a client that was
// already built. That is also why opting out is the only case that returns nil:
// a function resolved to nothing at construction time would pin its client to
// whatever was configured then. A nil URL from the returned function means no
// proxy is configured and the request goes direct.
// the configured network proxy when enabled. It returns nil when the caller
// opts out or no proxy is configured, meaning direct connection. The returned
// function is compatible with both http.Transport.Proxy and
// websocket.Dialer.Proxy.
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
if !useProxy {
return nil
}
return func(*http.Request) (*url.URL, error) {
return proxyURL(), nil
u := proxyURL()
if u == nil {
return nil
}
return http.ProxyURL(u)
}
// HTTPClient builds an http.Client that sends traffic through the configured
@@ -79,16 +67,10 @@ func HTTPClient(useProxy bool, timeout time.Duration) *http.Client {
// through the configured HTTP CONNECT proxy when enabled. Its signature
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
// to send SMTP over the proxy.
//
// Like ProxyFunc it reads the proxy address per dial, so clearing or changing
// system.networkProxy reaches a dialer that already exists.
func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) {
u := proxyURL()
return func(network, addr string, timeout time.Duration) (net.Conn, error) {
if !useProxy {
return net.DialTimeout(network, addr, timeout)
}
u := proxyURL()
if u == nil {
if !useProxy || u == nil {
return net.DialTimeout(network, addr, timeout)
}
return dialViaProxy(u, addr, timeout)
-133
View File
@@ -1,133 +0,0 @@
package netproxy
import (
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"nukumizu-backend/config"
)
// publishConfig writes a config.json and makes it the configuration in effect,
// which is what a settings update does.
func publishConfig(t *testing.T, body string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
if _, err := config.LoadGlobalConfig(path); err != nil {
t.Fatalf("LoadGlobalConfig: %v", err)
}
}
// resolve runs a proxy function and returns the URL it chose, or "" when it
// chose a direct connection.
func resolve(t *testing.T, proxy func(*http.Request) (*url.URL, error)) string {
t.Helper()
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
u, err := proxy(req)
if err != nil {
t.Fatalf("proxy function: %v", err)
}
if u == nil {
return ""
}
return u.String()
}
// TestProxyFuncResolvesPerCall is the property that makes system.networkProxy
// hot-reloadable: the function handed to a transport keeps reading the live
// configuration instead of the address that was configured when it was built.
func TestProxyFuncResolvesPerCall(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
proxy := ProxyFunc(true)
if proxy == nil {
t.Fatal("ProxyFunc(true) returned nil, so the channel would never proxy")
}
if got := resolve(t, proxy); got != "http://127.0.0.1:7890" {
t.Errorf("first resolution = %q", got)
}
// The same function must follow a settings update.
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:8888"}}`)
if got := resolve(t, proxy); got != "http://127.0.0.1:8888" {
t.Errorf("after a settings update the same function resolved %q", got)
}
// Clearing the proxy falls back to a direct connection.
publishConfig(t, `{"system":{"networkProxy":""}}`)
if got := resolve(t, proxy); got != "" {
t.Errorf("a cleared proxy still resolved %q", got)
}
}
func TestProxyFuncOptOutReturnsNil(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
// A channel with networkUseProxy off must not be handed a function at all,
// so its transport keeps the default direct dialing.
if ProxyFunc(false) != nil {
t.Error("ProxyFunc(false) must return nil")
}
}
func TestProxyFuncNormalizesMissingScheme(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"127.0.0.1:7890"}}`)
if got := resolve(t, ProxyFunc(true)); got != "http://127.0.0.1:7890" {
t.Errorf("resolved %q, want the http:// prefix added", got)
}
}
func TestProxyFuncIgnoresUnusableProxy(t *testing.T) {
// A value that cannot be parsed must leave the client dialing directly
// rather than failing every request.
publishConfig(t, `{"system":{"networkProxy":"://missing-scheme"}}`)
if got := resolve(t, ProxyFunc(true)); got != "" {
t.Errorf("an unparseable proxy resolved %q, want a direct connection", got)
}
}
// TestDialWithTimeoutDialsDirectlyWithoutProxy covers the path a cleared
// system.networkProxy takes: the dialer was built while a proxy was configured,
// and must fall back to a direct dial once there is none.
func TestDialWithTimeoutDialsDirectlyWithoutProxy(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
dial := DialWithTimeout(true)
if dial == nil {
t.Fatal("DialWithTimeout(true) returned nil")
}
// No proxy is listening on that address, so a dial attempted now would
// fail; clearing the setting is what makes the direct path reachable.
publishConfig(t, `{"system":{"networkProxy":""}}`)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err == nil {
conn.Close()
}
}()
conn, err := dial("tcp", ln.Addr().String(), 5*time.Second)
if err != nil {
t.Fatalf("dial through a cleared proxy: %v", err)
}
conn.Close()
}
-10
View File
@@ -107,7 +107,6 @@ type StatusChangeCallback func(change StatusChange)
// All methods are thread-safe.
type Tracker struct {
mu sync.RWMutex
count int
nodes map[string]*Node // uuid → Node
uuidToName map[string]string // uuid → name (from node list)
onlineSet map[string]bool // which uuids are currently online
@@ -162,13 +161,6 @@ func (t *Tracker) fireCallbacks(change StatusChange) {
}
}
// GetNodeCount returns the total number of nodes in the tracker.
func (t *Tracker) GetNodeCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return t.count
}
// UpdateNodeList replaces the full node list and updates each node's name and
// static Info metadata. entries maps UUID → node metadata from the Komari node
// list.
@@ -183,8 +175,6 @@ func (t *Tracker) UpdateNodeList(entries map[string]NodeListEntry) {
t.knownUUIDs[uuid] = true
}
t.count = len(entries)
t.uuidToName = make(map[string]string, len(entries))
for uuid, entry := range entries {
t.uuidToName[uuid] = entry.Name
+75 -45
View File
@@ -14,7 +14,7 @@ import (
"nukumizu-backend/global"
"nukumizu-backend/internal/controller"
"nukumizu-backend/internal/controller/pipes"
"nukumizu-backend/internal/controller/pipes/qq_onebot"
"nukumizu-backend/internal/controller/pipes/qq_napcat"
"nukumizu-backend/internal/controller/pipes/telegram"
"nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node"
@@ -58,27 +58,6 @@ func main() {
postLog.SetDebugMode(cfg.System.DebugMode)
postLog.InitLogBroadcaster()
// A settings update replaces the configuration in memory; these hooks push
// the new values into the state that was derived from the old one. The
// logger's debug flag is process-wide rather than read at every log call,
// and each controller holds its own copy of its channel settings plus the
// clients built from them.
config.OnReload(func(updated *config.Config) {
postLog.SetDebugMode(updated.System.DebugMode)
mgr := controller.GetManager()
if mgr == nil || !mgr.NeedsRebuild(updated.ControllerMethod) {
return
}
// Controller settings changed. Each controller reads its settings into
// fields when it is built, and the OneBot and Telegram ones own
// connections that cannot be re-pointed, so the change is applied by
// replacing the whole set rather than reconfiguring it in place.
postLog.Info("Controller settings changed; rebuilding every channel")
mgr.ReplaceAll(buildControllers(updated), updated.ControllerMethod)
})
dbPath := cfg.DBPath
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
@@ -131,7 +110,7 @@ func main() {
initControllers()
// Send the bot initialization message to all enabled bot controllers
// (QQ_OneBot, Telegram), then the server list right after it once the
// (QQ_NapCat, Telegram), then the server list right after it once the
// initial status refresh has completed so the list reflects the real
// online/offline state.
if mgr := controller.GetManager(); mgr != nil {
@@ -220,28 +199,83 @@ func startWebhookServer(cfg *config.Config) {
}()
}
// buildControllers constructs one controller per channel from the given
// configuration. The set is built fresh whenever controller settings change,
// because a controller reads its settings once at construction and two of them
// own connections that cannot be re-pointed.
func buildControllers(cfg *config.Config) []controller.Controller {
return []controller.Controller{
qq_onebot.NewQQController(cfg.ControllerMethod.QQ_OneBot),
telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
pipes.NewEmailController(cfg.ControllerMethod.Email),
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
pipes.NewWebhookController(cfg.ControllerMethod.Webhook),
}
}
// initControllers installs the initial controller set.
// initControllers initializes and starts all configured controllers.
func initControllers() {
cfg := config.Current()
cfg := config.C_globalConfig
mgr := controller.GetManager()
if mgr == nil || cfg == nil {
if mgr == nil {
return
}
mgr.ReplaceAll(buildControllers(cfg), cfg.ControllerMethod)
// QQ (Napcat) controller.
qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ)
mgr.Register(qqCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("QQ controller panic: %v", r))
}
}()
if err := qqCtrl.Start(); err != nil {
postLog.Error("Failed to start QQ controller: " + err.Error())
}
}()
// Telegram controller.
tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram)
mgr.Register(tgCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r))
}
}()
if err := tgCtrl.Start(); err != nil {
postLog.Error("Failed to start Telegram controller: " + err.Error())
}
}()
// Email controller (status-only).
emailCtrl := pipes.NewEmailController(cfg.ControllerMethod.Email)
mgr.Register(emailCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
}
}()
if err := emailCtrl.Start(); err != nil {
postLog.Error("Failed to start Email controller: " + err.Error())
}
}()
// Ntfy controller (status-only).
ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy)
mgr.Register(ntfyCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
}
}()
if err := ntfyCtrl.Start(); err != nil {
postLog.Error("Failed to start Ntfy controller: " + err.Error())
}
}()
// Webhook controller (status-only).
webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook)
mgr.Register(webhookCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
}
}()
if err := webhookCtrl.Start(); err != nil {
postLog.Error("Failed to start Webhook controller: " + err.Error())
}
}()
}
// startBackgroundTasks starts periodic background goroutines.
@@ -260,15 +294,11 @@ func startBackgroundTasks(_ *config.Config) {
if client == nil {
continue
}
orgNodeCount := node.GetTracker().GetNodeCount()
nodes, err := client.FetchNodes()
if err != nil {
postLog.Warning("Failed to refresh node list: " + err.Error())
continue
}
if node.GetTracker().GetNodeCount() != orgNodeCount {
postLog.Info(fmt.Sprintf("Refreshed node list: %d nodes", len(nodes)))
}
tracker := node.GetTracker()
if tracker != nil {
tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
+1 -1
View File
@@ -61,7 +61,7 @@ func SetupRouter() *http.ServeMux {
// SetupRouter it is served on its own listener (webhook.listenAddr/listenPort),
// so external applications can be given access to the webhook port without
// reaching the admin API. Every endpoint configured under webhook.endpoints is
// reachable as /api/webhook/post/{name}.
// reachable as /api/webhook/<name>.
func SetupWebhookRouter() *http.ServeMux {
postLog.Info("Setting up webhook routers...")
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
#
# Runs the server from source, with the build metadata stamped in.
#
# The console is embedded in the binary (web/embed.go), so compiling without
# web/dist fails. Say that plainly rather than leaving go:embed's error.
set -euo pipefail
# Run from the repository root, however the script was invoked.
cd "$(dirname "$0")"
if [ ! -f web/dist/index.html ]; then
echo "The web console is not built: web/dist is missing." >&2
echo "Run build-linux-x86_64.sh once, or \"npm run build\" in frontend/." >&2
exit 1
fi
# Get git commit hash (shortened to 7 characters, can also use full)
COMMIT=$(git rev-parse --short HEAD)
# Get UTC time
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build -ldflags
LDFLAGS="-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT"
go run -ldflags "$LDFLAGS" .