Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2159ff0c | ||
|
|
bc5cb417eb | ||
|
|
1a7a4cdc07 | ||
|
|
9137d53286 | ||
|
|
03e74f49c3 | ||
|
|
0c581abc4c | ||
|
|
ba0d8539b3 | ||
|
|
1b11be2427 | ||
|
|
72d0d587cb | ||
|
|
c5a80bd952 | ||
|
|
d06d0bcd2f | ||
|
|
aa7e7f87e1 | ||
|
|
c7642ecca5 | ||
|
|
457ea42417 | ||
|
|
7dc6089871 | ||
|
|
2a5a46a984 | ||
|
|
0691e85ccf | ||
|
|
d9a9918e21 | ||
|
|
ac5587d0c8 | ||
|
|
2524e40566 | ||
|
|
5167d799bc | ||
|
|
cb2df5076d | ||
|
|
2d5cf39151 | ||
|
|
05dcc3769d | ||
|
|
f36f17bd2a | ||
|
|
3877c6d128 | ||
|
|
3ce42b3cca | ||
|
|
057d7d584d | ||
|
|
90ca08066b | ||
|
|
d481f13d1d | ||
|
|
7ecf51eba5 |
No files matched your search
@@ -6,6 +6,10 @@ bot_node_config.json
|
|||||||
*.exe
|
*.exe
|
||||||
nukumizu-linux-amd64
|
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
|
# The built web console. web/embed.go compiles it into the binary, and the
|
||||||
# build-*.sh / build-*.bat scripts rebuild it before every compile.
|
# build-*.sh / build-*.bat scripts rebuild it before every compile.
|
||||||
/web/dist/
|
/web/dist/
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of eve
|
|||||||
- **Remote command execution** — dispatches commands through the Komari task API and polls the result (1s interval, up to 60s timeout).
|
- **Remote command execution** — dispatches commands through the Komari task API and polls the result (1s interval, up to 60s timeout).
|
||||||
- **Interactive bots** — QQ (NapCat / OneBot 11) and Telegram bots for `/list`, `/status`, `/info`, `/run`, `/shutdown`, `/reboot`, and more, protected by an admin / trusted-group permission model.
|
- **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.
|
- **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).
|
- **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`.
|
- **Customizable message templates** — every bot/notification message is rendered from a template in `config.json`, with Markdown formatting switched on per channel.
|
||||||
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
|
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
|
||||||
- **Dashboard API** — token-authenticated REST API plus a live log-streaming WebSocket.
|
- **Dashboard API** — token-authenticated REST API plus an admin-only live log-streaming WebSocket.
|
||||||
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust, and tailing logs. The built bundle is embedded in the binary, so a single executable serves both the API and the console.
|
- **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
|
## How it works
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of eve
|
|||||||
```
|
```
|
||||||
nukumizu-backend/
|
nukumizu-backend/
|
||||||
├── main.go # Entry point, startup sequence, graceful shutdown
|
├── main.go # Entry point, startup sequence, graceful shutdown
|
||||||
├── router.go # HTTP route registration
|
├── router.go # HTTP route registration (main + webhook API)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── config.go # Load config files, apply defaults
|
│ ├── config.go # Load config files, apply defaults
|
||||||
│ └── variables.go # Config schema structs + globals
|
│ └── variables.go # Config schema structs + globals
|
||||||
@@ -40,17 +41,19 @@ nukumizu-backend/
|
|||||||
│ ├── user.go # /api/user/login, /api/user/register
|
│ ├── user.go # /api/user/login, /api/user/register
|
||||||
│ ├── server.go # /api/server/list, getInfo, getStatus, exec
|
│ ├── server.go # /api/server/list, getInfo, getStatus, exec
|
||||||
│ ├── settings.go # /api/settings/get, set
|
│ ├── settings.go # /api/settings/get, set
|
||||||
|
│ ├── webhook.go # /api/webhook/post/{name} (incoming webhook API)
|
||||||
|
│ ├── webhook_endpoints.go # /api/webhook/add, modify, delete, list
|
||||||
│ └── health.go # /health
|
│ └── health.go # /health
|
||||||
├── database/
|
├── database/
|
||||||
│ └── user.go # user.db (SQLite) user store
|
│ └── user.go # user.db (SQLite) user store
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── auth.go # Token management, Auth middleware, JSON responses
|
│ ├── auth.go # Token management, Auth middleware, JSON responses
|
||||||
│ └── middleware.go # Rate limit, CORS, XSS/security headers
|
│ └── middleware.go # Rate limit, CORS, XSS headers, WebSocket auth
|
||||||
├── postLog/ # Logging subsystem
|
├── postLog/ # Logging subsystem
|
||||||
│ ├── postLog.go # Leveled logger (stdout + broadcast)
|
│ ├── postLog.go # Leveled logger (stdout + broadcast)
|
||||||
│ ├── database.go # log.db (SQLite, one table per run)
|
│ ├── database.go # log.db (SQLite, one table per run)
|
||||||
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
||||||
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
|
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler (admin only)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # Embeds the built console (web/dist) in the binary
|
│ ├── embed.go # Embeds the built console (web/dist) in the binary
|
||||||
│ ├── dist/ # Vite build output — generated, gitignored
|
│ ├── dist/ # Vite build output — generated, gitignored
|
||||||
@@ -66,17 +69,17 @@ nukumizu-backend/
|
|||||||
│ ├── template/
|
│ ├── template/
|
||||||
│ │ └── template.go # Message template renderer ({{ variables }})
|
│ │ └── template.go # Message template renderer ({{ variables }})
|
||||||
│ └── controller/
|
│ └── controller/
|
||||||
│ ├── controller.go # Manager, Controller / BotController interfaces
|
│ ├── controller.go # Manager, Controller / BotController interfaces, alerts
|
||||||
│ ├── trigger.go # Command parsing, authorization, routing
|
│ ├── trigger.go # Command parsing, authorization, routing
|
||||||
│ ├── processor.go # Command handlers
|
│ ├── processor.go # Command handlers
|
||||||
│ ├── utils.go
|
│ ├── utils.go
|
||||||
│ └── pipes/
|
│ └── pipes/
|
||||||
│ ├── email.go # Email notification pipe
|
│ ├── email.go # Email notification pipe
|
||||||
│ ├── ntfy.go # ntfy notification pipe
|
│ ├── ntfy.go # ntfy notification pipe
|
||||||
│ ├── webhook.go # Webhook notification pipe
|
│ ├── webhook.go # Outgoing webhook notification pipe
|
||||||
│ ├── qq_napcat/
|
│ ├── qq_onebot/
|
||||||
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||||
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
│ │ └── onebot.go # OneBot 11 WebSocket client (events + API)
|
||||||
│ └── telegram/
|
│ └── telegram/
|
||||||
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||||
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
||||||
@@ -89,8 +92,8 @@ nukumizu-backend/
|
|||||||
├── api/index.js # Wrappers around the REST endpoints
|
├── api/index.js # Wrappers around the REST endpoints
|
||||||
├── router/index.js # Routes and the login guard
|
├── router/index.js # Routes and the login guard
|
||||||
├── utils/ # http/auth/theme/toast/format/runtime helpers
|
├── utils/ # http/auth/theme/toast/format/runtime helpers
|
||||||
├── components/ # Modal, Toggle, editors, top bar, side bar
|
├── components/ # Modal, Toggle, editors, ConfigSection, top bar, side bar
|
||||||
└── views/ # Login, Overview, Trusted, Settings, Logs
|
└── views/ # Login, Overview, Trusted, Settings, WebHooks, Logs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -98,7 +101,7 @@ nukumizu-backend/
|
|||||||
- Go **1.25** or newer
|
- 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
|
- [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
|
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||||
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint
|
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 forward WebSocket endpoint (`/api` and `/event`)
|
||||||
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
- 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`.
|
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
|
||||||
@@ -139,9 +142,22 @@ There are two configuration files, both read from the working directory unless o
|
|||||||
"password": "CHANGE_ME"
|
"password": "CHANGE_ME"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"webhook": {
|
||||||
|
"enabled": false,
|
||||||
|
"listenAddr": "0.0.0.0",
|
||||||
|
"listenPort": "8081",
|
||||||
|
"endpoints": {
|
||||||
|
"example": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "CHANGE_ME",
|
||||||
|
"notifyPipes": ["qq(napcat)", "telegram", "email", "ntfy"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"controllerMethod": {
|
"controllerMethod": {
|
||||||
"qq(napcat)": {
|
"qq(napcat)": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"markdown": false,
|
||||||
"networkUseProxy": false,
|
"networkUseProxy": false,
|
||||||
"napcatAddr": "127.0.0.1",
|
"napcatAddr": "127.0.0.1",
|
||||||
"napcatPort": "3000",
|
"napcatPort": "3000",
|
||||||
@@ -151,12 +167,14 @@ There are two configuration files, both read from the working directory unless o
|
|||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"markdown": true,
|
||||||
"networkUseProxy": false,
|
"networkUseProxy": false,
|
||||||
"botToken": "",
|
"botToken": "",
|
||||||
"listenMethod": "global"
|
"listenMethod": "global"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"markdown": false,
|
||||||
"networkUseProxy": false,
|
"networkUseProxy": false,
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpPort": 587,
|
"smtpPort": 587,
|
||||||
@@ -168,6 +186,7 @@ There are two configuration files, both read from the working directory unless o
|
|||||||
},
|
},
|
||||||
"ntfy": {
|
"ntfy": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"markdown": false,
|
||||||
"networkUseProxy": false,
|
"networkUseProxy": false,
|
||||||
"server": "https://ntfy.sh",
|
"server": "https://ntfy.sh",
|
||||||
"topic": "",
|
"topic": "",
|
||||||
@@ -176,6 +195,7 @@ There are two configuration files, both read from the working directory unless o
|
|||||||
},
|
},
|
||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"markdown": false,
|
||||||
"networkUseProxy": false,
|
"networkUseProxy": false,
|
||||||
"url": "",
|
"url": "",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
@@ -198,12 +218,14 @@ There are two configuration files, both read from the working directory unless o
|
|||||||
|
|
||||||
Field notes:
|
Field notes:
|
||||||
|
|
||||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, QQ OneBot 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(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
|
- `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`.
|
- `debug` toggles verbose per-channel message/action logging; these only matter in debug builds / `debugMode`.
|
||||||
- `email.useTLS` is kept for configuration compatibility.
|
- `email.useTLS` is kept for configuration compatibility.
|
||||||
- `dataPath` / `dbPath` default to `./data` and `./db`; `user.db` and `log.db` are created under `dbPath`.
|
- `dataPath` / `dbPath` default to `./data` and `./db`; `user.db` and `log.db` are created under `dbPath`.
|
||||||
- Missing keys fall back to built-in defaults (host `0.0.0.0`, port `8080`, NapCat `127.0.0.1:3000`, ntfy server `https://ntfy.sh`, webhook method `POST`, etc.). Message templates have built-in fallbacks too.
|
- 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`
|
### `bot_user_config.json`
|
||||||
|
|
||||||
@@ -256,7 +278,7 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
|
|||||||
|
|
||||||
### Message templates
|
### Message templates
|
||||||
|
|
||||||
`controllerMessage` templates are rendered before sending. Available variables (rendered through the Telegram pipe are additionally wrapped in Telegram legacy Markdown):
|
`controllerMessage` templates are rendered before sending. Available variables (channels with `markdown: true` additionally wrap the verbatim values in Markdown — see the field notes above):
|
||||||
|
|
||||||
| Variable | Meaning |
|
| Variable | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -272,6 +294,18 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
|
|||||||
| `{{ list.offlineServers }}` | Formatted list of offline servers |
|
| `{{ 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) |
|
| `{{ 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, NapCat 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. |
|
||||||
|
| `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`, `dataPath`, `dbPath`, `komari.dashboardURL` | **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
|
## 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.
|
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.
|
||||||
@@ -287,6 +321,8 @@ Requests are authenticated with HTTP headers:
|
|||||||
|
|
||||||
Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any authenticated call refreshes the timer. Permission levels: `None`, `bot`, `admin`. Endpoints requiring `bot` accept both `bot` and `admin` tokens. Currently registration/login always issue `admin`-level tokens.
|
Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any authenticated call refreshes the timer. Permission levels: `None`, `bot`, `admin`. Endpoints requiring `bot` accept both `bot` and `admin` tokens. Currently registration/login always issue `admin`-level tokens.
|
||||||
|
|
||||||
|
Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLogs` also accepts the same credentials as `?token=` and `?timestamp=` query parameters (headers still take precedence when both are present). Only `admin` tokens are accepted; the timestamp is checked once at handshake time, so an accepted connection stays open past its tolerance window. Because the query string can leak into proxy and access logs, a token-carrying WebSocket URL should be treated as a secret.
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Permission | Description |
|
| Endpoint | Method | Permission | Description |
|
||||||
@@ -298,19 +334,60 @@ Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any aut
|
|||||||
| `/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/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/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}`, where `config` is the selected config file's content (same layout as the JSON file). |
|
| `/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/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. 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/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. |
|
||||||
|
| `/api/webhook/list` | GET | admin | Every configured incoming webhook endpoint, keyed by name, under `data.endpoints`. |
|
||||||
| `/health` | GET | None | Health check. Returns `data: {status, database}`. |
|
| `/health` | GET | None | Health check. Returns `data: {status, database}`. |
|
||||||
| `/api/system/getLogs` | WebSocket | None | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. |
|
| `/api/system/getLogs` | WebSocket | admin | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. Credentials via `X-Token`/`X-Timestamp` headers or `?token=`/`?timestamp=` query parameters; a failed check answers with the JSON error and no upgrade. |
|
||||||
|
|
||||||
Middleware applied to the whole server:
|
Middleware applied to the whole server:
|
||||||
|
|
||||||
- **Rate limit** — token bucket, 100 requests/minute per client IP.
|
- **Rate limit** — token bucket, 100 requests/minute per client IP.
|
||||||
- **CORS** — `Access-Control-Allow-Origin: *`, allows `Content-Type`, `X-Token`, `X-Timestamp`, `Authorization`.
|
- **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.
|
- **Security headers** — `X-XSS-Protection`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, a restrictive CSP.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
A listener of its own, so external applications can be pointed at it without being able to reach the admin API. It is switched on with `webhook.enabled` and binds `webhook.listenAddr:webhook.listenPort` (default `0.0.0.0:8081`); that half of the configuration is applied at startup, while `webhook.endpoints` is re-read whenever the config is reloaded. Only the rate limit and CORS middleware apply here — no session token is involved.
|
||||||
|
|
||||||
|
The console's **WebHooks** page manages the listener settings and the endpoints. The outgoing WebHook notification channel (`controllerMethod.webhook`) stays on the Settings page with the other notification channels, since it is one of them.
|
||||||
|
|
||||||
|
| Endpoint | Method | Permission | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/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 (`/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(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.
|
||||||
|
|
||||||
|
The alert is rendered per channel as:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ subject }}
|
||||||
|
- Source: {{ source }}
|
||||||
|
- Content:
|
||||||
|
{{ content }}
|
||||||
|
|
||||||
|
- Time: {{ time }}
|
||||||
|
Sent by Nukumizu Alert System
|
||||||
|
```
|
||||||
|
|
||||||
|
`{{ source }}` is the endpoint name, so recipients can tell which application triggered the alert. On a channel with `markdown: true` the source is wrapped in inline code and the content in a fenced code block; `{{ subject }}` and `{{ time }}` stay plain.
|
||||||
|
|
||||||
|
Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`, `401` wrong token, `403` endpoint disabled, `404` unknown endpoint name, `405` non-POST request, `500` endpoint has no token configured, `502` no channel accepted the alert.
|
||||||
|
|
||||||
## Bots
|
## Bots
|
||||||
|
|
||||||
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 (WebSocket event stream + HTTP actions); Telegram uses `go-telegram/bot` long polling.
|
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 over WebSocket (event stream + actions); Telegram uses `go-telegram/bot` long polling.
|
||||||
|
|
||||||
### Permission model
|
### Permission model
|
||||||
|
|
||||||
@@ -341,6 +418,8 @@ QQ (NapCat) and Telegram bots share one command engine and authorization pipelin
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires Go 1.25+ and — to build the web console — Node.js 22+.
|
Requires Go 1.25+ and — to build the web console — Node.js 22+.
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,24 @@
|
|||||||
## Ver.0.1.2.4
|
## Ver.0.2.1.6-d06d0bc.pre-release
|
||||||
|
### Features
|
||||||
|
- Most of settings now support hot reload when changed
|
||||||
|
- [Network proxy]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
|
||||||
|
- [Controllers]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/2a5a46a9846c5afe8ac3c66dc42164ddea25c038")
|
||||||
|
- [Only log node count when it changed]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/aa7e7f87e17f17ff2f64aa1823a00a291628d4eb")
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- [Resolve the proxy address per request]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
|
||||||
|
|
||||||
|
## Ver.0.2.0.5-661cf08.pre-release
|
||||||
|
### Features
|
||||||
|
- [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
|
### Bug Fixes
|
||||||
- [Integrate frontend build into backend binary]("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
|
## Ver.0.1.2.3-1c4ad61.pre-release
|
||||||
### Features
|
### Features
|
||||||
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
|
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-21
@@ -5,19 +5,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||||
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||||
// other config files this one is auto-generated and optional: a missing or
|
// other config files this one is auto-generated and optional: a missing or
|
||||||
// empty file yields an empty map, so every node falls back to its default
|
// empty file yields an empty map, so every node falls back to its defaults —
|
||||||
// enableStatusNotify value (true).
|
// enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
|
||||||
func LoadBotNodeConfig(configPath string) error {
|
func LoadBotNodeConfig(configPath string) error {
|
||||||
cfg := BotNodeMembers{}
|
cfg := BotNodeMembers{}
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
C_botNodeConfig = cfg
|
botNodeConfig.Store(&cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to read bot node config file: %w", err)
|
return fmt.Errorf("failed to read bot node config file: %w", err)
|
||||||
@@ -27,25 +28,44 @@ func LoadBotNodeConfig(configPath string) error {
|
|||||||
return fmt.Errorf("failed to parse bot node config file: %w", err)
|
return fmt.Errorf("failed to parse bot node config file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
C_botNodeConfig = cfg
|
botNodeConfig.Store(&cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeStatusNotifyEnabled reports whether the node identified by uuid should
|
// NodeStatusNotifyEnabled reports whether the node identified by uuid should
|
||||||
// broadcast status-change notifications, per bot_node_config.json.
|
// broadcast status-change notifications, per bot_node_config.json.
|
||||||
// enableStatusNotify defaults to true: a node notifies unless its entry
|
// enableStatusNotify defaults to true: a node notifies unless its entry
|
||||||
// explicitly sets the flag to false.
|
// explicitly sets the flag to false. A missing configuration (nil map) yields
|
||||||
|
// the same default.
|
||||||
func NodeStatusNotifyEnabled(uuid string) bool {
|
func NodeStatusNotifyEnabled(uuid string) bool {
|
||||||
if C_botNodeConfig == nil {
|
opts, ok := BotNodes()[uuid]
|
||||||
return true
|
|
||||||
}
|
|
||||||
opts, ok := C_botNodeConfig[uuid]
|
|
||||||
if !ok || opts.EnableStatusNotify == nil {
|
if !ok || opts.EnableStatusNotify == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return *opts.EnableStatusNotify
|
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,
|
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||||
// and stores it as a global singleton.
|
// and stores it as a global singleton.
|
||||||
func LoadGlobalConfig(configPath string) (*Config, error) {
|
func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||||
@@ -67,15 +87,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
|||||||
cfg.System.ListenPort = "8080"
|
cfg.System.ListenPort = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults for QQ controller.
|
// Apply defaults for QQ_OneBot controller.
|
||||||
if cfg.ControllerMethod.QQ.ListenMethod == "" {
|
if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
|
||||||
cfg.ControllerMethod.QQ.ListenMethod = "global"
|
cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
|
||||||
}
|
}
|
||||||
if cfg.ControllerMethod.QQ.NapcatAddr == "" {
|
if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
|
||||||
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1"
|
cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if cfg.ControllerMethod.QQ.NapcatPort == "" {
|
if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
|
||||||
cfg.ControllerMethod.QQ.NapcatPort = "3000"
|
cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults for Telegram controller.
|
// Apply defaults for Telegram controller.
|
||||||
@@ -107,6 +127,17 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
|||||||
cfg.ControllerMethod.Webhook.Headers = map[string]string{}
|
cfg.ControllerMethod.Webhook.Headers = map[string]string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply defaults for the incoming webhook API.
|
||||||
|
if cfg.Webhook.ListenAddr == "" {
|
||||||
|
cfg.Webhook.ListenAddr = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if cfg.Webhook.ListenPort == "" {
|
||||||
|
cfg.Webhook.ListenPort = "8081"
|
||||||
|
}
|
||||||
|
if cfg.Webhook.Endpoints == nil {
|
||||||
|
cfg.Webhook.Endpoints = map[string]WebhookEndpointConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply defaults for paths.
|
// Apply defaults for paths.
|
||||||
if cfg.DataPath == "" {
|
if cfg.DataPath == "" {
|
||||||
cfg.DataPath = "./data"
|
cfg.DataPath = "./data"
|
||||||
@@ -135,7 +166,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 }}"
|
cfg.ControllerMessage.ServerExecuteResult = "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
C_globalConfig = &cfg
|
globalConfig.Store(&cfg)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +182,7 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
|||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse bot user config file: %w", err)
|
return nil, fmt.Errorf("failed to parse bot user config file: %w", err)
|
||||||
}
|
}
|
||||||
C_botUserConfig = &cfg
|
botUserConfig.Store(&cfg)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +190,8 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
|||||||
// which is a plain registry of the nodes Komari reports (read back at startup
|
// which is a plain registry of the nodes Komari reports (read back at startup
|
||||||
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||||
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||||
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
// any per-node option a user set by hand — enableStatusNotify or flap_tolerant
|
||||||
|
// — is left untouched. UUIDs seen for
|
||||||
// the first time are added as an empty object, i.e. no parameter is written, so
|
// 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
|
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||||
// its object keys emitted in sorted order by encoding/json, keeping the file
|
// its object keys emitted in sorted order by encoding/json, keeping the file
|
||||||
@@ -202,8 +234,9 @@ func SaveBotNodeConfig(configPath string, uuids []string) error {
|
|||||||
|
|
||||||
// IsDebugMode returns whether debug mode is enabled.
|
// IsDebugMode returns whether debug mode is enabled.
|
||||||
func IsDebugMode() bool {
|
func IsDebugMode() bool {
|
||||||
if C_globalConfig == nil {
|
cfg := Current()
|
||||||
|
if cfg == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return C_globalConfig.System.DebugMode
|
return cfg.System.DebugMode
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"nukumizu-backend/global"
|
"nukumizu-backend/global"
|
||||||
@@ -23,6 +24,89 @@ const (
|
|||||||
// accepted constants above.
|
// accepted constants above.
|
||||||
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
|
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
|
// settingsLock serializes read-modify-write access to the on-disk configuration
|
||||||
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
||||||
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
||||||
@@ -82,13 +166,46 @@ func GetSettings(settingsType string) ([]byte, error) {
|
|||||||
// written the matching in-memory singleton is reloaded so runtime code observes
|
// written the matching in-memory singleton is reloaded so runtime code observes
|
||||||
// the new values.
|
// the new values.
|
||||||
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
||||||
path, err := settingsPath(settingsType)
|
return runSettingsUpdate(func() (*Config, error) {
|
||||||
|
return updateSettingsLocked(settingsType, patch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsLock.Lock()
|
notifyReload(cfg)
|
||||||
defer settingsLock.Unlock()
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
path, err := settingsPath(settingsType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Start from whatever is already on disk so nothing is dropped. A missing or
|
// Start from whatever is already on disk so nothing is dropped. A missing or
|
||||||
// empty file is treated as an empty object.
|
// empty file is treated as an empty object.
|
||||||
@@ -97,23 +214,23 @@ func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
if len(bytes.TrimSpace(data)) > 0 {
|
if len(bytes.TrimSpace(data)) > 0 {
|
||||||
if err := json.Unmarshal(data, ¤t); err != nil {
|
if err := json.Unmarshal(data, ¤t); err != nil {
|
||||||
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
|
return nil, fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deepMergeSettings(current, patch)
|
deepMergeSettings(current, patch)
|
||||||
|
|
||||||
data, err = json.MarshalIndent(current, "", " ")
|
data, err = json.MarshalIndent(current, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
|
return nil, fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
|
||||||
}
|
}
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
|
|
||||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
|
return nil, fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reloadSettings(settingsType, path)
|
return reloadSettings(settingsType, path)
|
||||||
@@ -144,18 +261,19 @@ func deepMergeSettings(dst, src map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reloadSettings refreshes the in-memory singleton for the given settings type
|
// reloadSettings refreshes the in-memory singleton for the given settings type
|
||||||
// so the running program observes the values just persisted to disk.
|
// so the running program observes the values just persisted to disk. Only
|
||||||
func reloadSettings(settingsType, path string) error {
|
// 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) {
|
||||||
switch settingsType {
|
switch settingsType {
|
||||||
case SettingGlobal:
|
case SettingGlobal:
|
||||||
_, err := LoadGlobalConfig(path)
|
return LoadGlobalConfig(path)
|
||||||
return err
|
|
||||||
case SettingBotUserConfig:
|
case SettingBotUserConfig:
|
||||||
_, err := LoadBotUserConfig(path)
|
_, err := LoadBotUserConfig(path)
|
||||||
return err
|
return nil, err
|
||||||
case SettingBotNodeConfig:
|
case SettingBotNodeConfig:
|
||||||
return LoadBotNodeConfig(path)
|
return nil, LoadBotNodeConfig(path)
|
||||||
default:
|
default:
|
||||||
return ErrUnsupportedSettingsType
|
return nil, ErrUnsupportedSettingsType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nukumizu-backend/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTempConfig writes content to a fresh temp file and points the matching
|
||||||
|
// global.ConfigPath field at it, returning a cleanup that restores the original.
|
||||||
|
func writeTempConfig(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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettingsDeepMerge(t *testing.T) {
|
||||||
|
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
|
||||||
|
"qq(napcat)": {
|
||||||
|
"admins": {
|
||||||
|
"100000001": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trustedGroups": {
|
||||||
|
"200000002": {
|
||||||
|
"event_status_notify": true,
|
||||||
|
"event_bot_started": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
patch := map[string]interface{}{
|
||||||
|
"qq(napcat)": map[string]interface{}{
|
||||||
|
"admins": map[string]interface{}{
|
||||||
|
"100000001": map[string]interface{}{
|
||||||
|
"event_status_notify": false, // toggle an existing nested flag
|
||||||
|
"event_reply": true, // add a key that is not in the file
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"trustedGroups": map[string]interface{}{
|
||||||
|
"12345": map[string]interface{}{ // add a whole new member
|
||||||
|
"event_bot_started": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := UpdateSettings(SettingBotUserConfig, patch); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := GetSettings(SettingBotUserConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`"event_status_notify": false`,
|
||||||
|
`"event_reply": true`,
|
||||||
|
`"event_bot_started": true`,
|
||||||
|
`"event_status_notify": true`, // sibling under trustedGroups 200000002 kept
|
||||||
|
`"12345"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("merged config missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The in-memory singleton must reflect the merged file too.
|
||||||
|
users := BotUsers()
|
||||||
|
if users == nil {
|
||||||
|
t.Fatal("bot user config not reloaded")
|
||||||
|
}
|
||||||
|
if users.QQ.Admins["100000001"].EventStatusNotify {
|
||||||
|
t.Error("expected reloaded admin event_status_notify = false")
|
||||||
|
}
|
||||||
|
if !users.QQ.Admins["100000001"].EventReply {
|
||||||
|
t.Error("expected reloaded admin event_reply = true")
|
||||||
|
}
|
||||||
|
if !users.QQ.TrustedGroups["12345"].EventBotStarted {
|
||||||
|
t.Error("expected new trusted group event_bot_started = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettingsReplacesArraysAndKeepsNumbers(t *testing.T) {
|
||||||
|
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||||
|
"system": {
|
||||||
|
"debugMode": true,
|
||||||
|
"listenPort": "8080"
|
||||||
|
},
|
||||||
|
"controllerMethod": {
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"smtpHost": "smtp.example.com",
|
||||||
|
"smtpPort": 587,
|
||||||
|
"to": ["old@example.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
patch := map[string]interface{}{
|
||||||
|
"system": map[string]interface{}{
|
||||||
|
"debugMode": false, // partial: listenPort must survive
|
||||||
|
},
|
||||||
|
"controllerMethod": map[string]interface{}{
|
||||||
|
"email": map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"smtpPort": json.Number("465"),
|
||||||
|
"to": []interface{}{"new@example.com"}, // arrays replace, not merge
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := UpdateSettings(SettingGlobal, patch); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := GetSettings(SettingGlobal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`"debugMode": false`,
|
||||||
|
`"listenPort": "8080"`, // sibling untouched
|
||||||
|
`"smtpHost": "smtp.example.com"`, // sibling untouched
|
||||||
|
`"smtpPort": 465`, // number kept verbatim, not 465.0
|
||||||
|
`"new@example.com"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("merged config missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "old@example.com") {
|
||||||
|
t.Errorf("array was merged instead of replaced:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
t.Errorf("expected %q to be a valid settings type", valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", "system", "node", "bot"} {
|
||||||
|
if IsValidSettingsType(invalid) {
|
||||||
|
t.Errorf("expected %q to be an invalid settings type", invalid)
|
||||||
|
}
|
||||||
|
if _, err := GetSettings(invalid); err != ErrUnsupportedSettingsType {
|
||||||
|
t.Errorf("GetSettings(%q) error = %v, want ErrUnsupportedSettingsType", invalid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettingsBotNodeMissingFile(t *testing.T) {
|
||||||
|
// Point at a temp path that does not exist yet.
|
||||||
|
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, "")
|
||||||
|
os.Remove(global.ConfigPath.BotNodeConfig)
|
||||||
|
|
||||||
|
data, err := GetSettings(SettingBotNodeConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings on missing bot_node_config: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "{}" {
|
||||||
|
t.Errorf("expected empty object for missing bot_node_config, got %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettingsRemovesKeysWithNull(t *testing.T) {
|
||||||
|
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{
|
||||||
|
"qq(napcat)": {
|
||||||
|
"admins": {
|
||||||
|
"100000001": { "event_status_notify": true },
|
||||||
|
"200000002": { "event_status_notify": false }
|
||||||
|
},
|
||||||
|
"trustedGroups": {
|
||||||
|
"999": { "event_bot_started": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"admins": {}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// null removes a nested member and keeps its siblings; an empty section stays.
|
||||||
|
patch := map[string]interface{}{
|
||||||
|
"qq(napcat)": map[string]interface{}{
|
||||||
|
"admins": map[string]interface{}{
|
||||||
|
"100000001": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := UpdateSettings(SettingBotUserConfig, patch); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := GetSettings(SettingBotUserConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSettings: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
if strings.Contains(got, "100000001") {
|
||||||
|
t.Errorf("deleted member still present:\n%s", got)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"200000002", `"trustedGroups"`, `"telegram"`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("unrelated content missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-25
@@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "sort"
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
// SystemConfig holds system-level configuration.
|
// SystemConfig holds system-level configuration.
|
||||||
type SystemConfig struct {
|
type SystemConfig struct {
|
||||||
@@ -12,12 +15,13 @@ type SystemConfig struct {
|
|||||||
|
|
||||||
// DebugConfig holds debug-level configuration.
|
// DebugConfig holds debug-level configuration.
|
||||||
type DebugConfig struct {
|
type DebugConfig struct {
|
||||||
ShowNapcatMsg bool `json:"showNapcatMsg"`
|
ShowOneBotMsg bool `json:"showOneBotMsg"`
|
||||||
ShowNapcatAction bool `json:"showNapcatAction"`
|
ShowOneBotAction bool `json:"showOneBotAction"`
|
||||||
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
||||||
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
||||||
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
||||||
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"`
|
OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
|
||||||
|
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KomariAccount holds Komari login credentials.
|
// KomariAccount holds Komari login credentials.
|
||||||
@@ -32,19 +36,21 @@ type KomariConfig struct {
|
|||||||
Account KomariAccount `json:"account"`
|
Account KomariAccount `json:"account"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
// QQConfig holds QQ (OneBot) Bot controller configuration.
|
||||||
type QQConfig struct {
|
type OneBotConfig struct {
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
NapcatAddr string `json:"napcatAddr"`
|
OneBotAddr string `json:"oneBotAddr"`
|
||||||
NapcatPort string `json:"napcatPort"`
|
OneBotPort string `json:"oneBotPort"`
|
||||||
NapcatToken string `json:"napcatToken"`
|
OneBotToken string `json:"oneBotToken"`
|
||||||
BotQQID int64 `json:"botQQID"`
|
BotQQID int64 `json:"botQQID"`
|
||||||
ListenMethod string `json:"listenMethod"`
|
ListenMethod string `json:"listenMethod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelegramConfig holds Telegram Bot controller configuration.
|
// TelegramConfig holds Telegram Bot controller configuration.
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
BotToken string `json:"botToken"`
|
BotToken string `json:"botToken"`
|
||||||
@@ -53,6 +59,7 @@ type TelegramConfig struct {
|
|||||||
|
|
||||||
// EmailConfig holds Email notification controller configuration.
|
// EmailConfig holds Email notification controller configuration.
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
@@ -66,6 +73,7 @@ type EmailConfig struct {
|
|||||||
|
|
||||||
// NtfyConfig holds Ntfy notification controller configuration.
|
// NtfyConfig holds Ntfy notification controller configuration.
|
||||||
type NtfyConfig struct {
|
type NtfyConfig struct {
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
Server string `json:"server"`
|
Server string `json:"server"`
|
||||||
@@ -74,8 +82,11 @@ type NtfyConfig struct {
|
|||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookConfig holds Webhook notification controller configuration.
|
// WebhookConfig holds the outgoing Webhook notification controller
|
||||||
|
// configuration. It is the counterpart of WebhookReceiverConfig, which serves
|
||||||
|
// the incoming webhook API.
|
||||||
type WebhookConfig struct {
|
type WebhookConfig struct {
|
||||||
|
Markdown bool `json:"markdown"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -86,11 +97,55 @@ type WebhookConfig struct {
|
|||||||
|
|
||||||
// ControllerMethodConfig holds all controller method configurations.
|
// ControllerMethodConfig holds all controller method configurations.
|
||||||
type ControllerMethodConfig struct {
|
type ControllerMethodConfig struct {
|
||||||
QQ QQConfig `json:"qq(napcat)"`
|
QQ_OneBot OneBotConfig `json:"qq_onebot"`
|
||||||
Telegram TelegramConfig `json:"telegram"`
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
Email EmailConfig `json:"email"`
|
Email EmailConfig `json:"email"`
|
||||||
Ntfy NtfyConfig `json:"ntfy"`
|
Ntfy NtfyConfig `json:"ntfy"`
|
||||||
Webhook WebhookConfig `json:"webhook"`
|
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/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
|
||||||
|
// endpoint answers with 403.
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// Token is the shared secret the caller must send in the request body. An
|
||||||
|
// endpoint without a token is rejected: an empty token would make the
|
||||||
|
// endpoint an open relay, so it is treated as a configuration error.
|
||||||
|
Token string `json:"token"`
|
||||||
|
|
||||||
|
// NotifyPipes lists the notification channels the alert is delivered to, by
|
||||||
|
// controller name (e.g. "qq(onebot)", "telegram", "email", "ntfy",
|
||||||
|
// "webhook").
|
||||||
|
NotifyPipes []string `json:"notifyPipes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookReceiverConfig holds the incoming webhook API settings. The API is
|
||||||
|
// served on its own listener instead of the main one, so external applications
|
||||||
|
// can be given access to the webhook port without exposing the admin API. Only
|
||||||
|
// the endpoints map is re-read on a settings update; enabled, listenAddr and
|
||||||
|
// listenPort are applied at startup.
|
||||||
|
type WebhookReceiverConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ListenAddr string `json:"listenAddr"`
|
||||||
|
ListenPort string `json:"listenPort"`
|
||||||
|
Endpoints map[string]WebhookEndpointConfig `json:"endpoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return WebhookEndpointConfig{}, false
|
||||||
|
}
|
||||||
|
endpoint, ok := cfg.Webhook.Endpoints[name]
|
||||||
|
return endpoint, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerMessageConfig holds message templates for controller responses.
|
// ControllerMessageConfig holds message templates for controller responses.
|
||||||
@@ -108,13 +163,26 @@ type Config struct {
|
|||||||
System SystemConfig `json:"system"`
|
System SystemConfig `json:"system"`
|
||||||
Debug DebugConfig `json:"debug"`
|
Debug DebugConfig `json:"debug"`
|
||||||
Komari KomariConfig `json:"komari"`
|
Komari KomariConfig `json:"komari"`
|
||||||
|
Webhook WebhookReceiverConfig `json:"webhook"`
|
||||||
ControllerMethod ControllerMethodConfig `json:"controllerMethod"`
|
ControllerMethod ControllerMethodConfig `json:"controllerMethod"`
|
||||||
ControllerMessage ControllerMessageConfig `json:"controllerMessage"`
|
ControllerMessage ControllerMessageConfig `json:"controllerMessage"`
|
||||||
DataPath string `json:"dataPath"`
|
DataPath string `json:"dataPath"`
|
||||||
DBPath string `json:"dbPath"`
|
DBPath string `json:"dbPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var C_globalConfig *Config
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
// BotUserOptions holds per-member options stored in bot_user_config.json.
|
// BotUserOptions holds per-member options stored in bot_user_config.json.
|
||||||
type BotUserOptions struct {
|
type BotUserOptions struct {
|
||||||
@@ -149,7 +217,7 @@ func (m BotUserMembers) IDs() []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// BotUser_QQConfig holds the admins and trusted groups for the QQ (Napcat) bot.
|
// BotUser_QQConfig holds the admins and trusted groups for the QQ (OneBot) bot.
|
||||||
type BotUser_QQConfig struct {
|
type BotUser_QQConfig struct {
|
||||||
Admins BotUserMembers `json:"admins"`
|
Admins BotUserMembers `json:"admins"`
|
||||||
TrustedGroups BotUserMembers `json:"trustedGroups"`
|
TrustedGroups BotUserMembers `json:"trustedGroups"`
|
||||||
@@ -165,11 +233,20 @@ type BotUser_TelegramConfig struct {
|
|||||||
// and trusted groups (chat targets) for each bot channel separately from the
|
// and trusted groups (chat targets) for each bot channel separately from the
|
||||||
// main config.json.
|
// main config.json.
|
||||||
type BotUserConfig struct {
|
type BotUserConfig struct {
|
||||||
QQ BotUser_QQConfig `json:"qq(napcat)"`
|
QQ BotUser_QQConfig `json:"qq_onebot"`
|
||||||
Telegram BotUser_TelegramConfig `json:"telegram"`
|
Telegram BotUser_TelegramConfig `json:"telegram"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var C_botUserConfig *BotUserConfig
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
// BotNodeOptions holds per-node options stored in bot_node_config.json. The
|
// 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;
|
// file is auto-populated by the node tracker for every node Komari reports;
|
||||||
@@ -181,12 +258,35 @@ type BotNodeOptions struct {
|
|||||||
// bool) lets an absent field be told apart from an explicit false, and
|
// bool) lets an absent field be told apart from an explicit false, and
|
||||||
// omitempty keeps untouched nodes stored as {}.
|
// omitempty keeps untouched nodes stored as {}.
|
||||||
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
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
|
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||||
// options.
|
// options.
|
||||||
type BotNodeMembers map[string]BotNodeOptions
|
type BotNodeMembers map[string]BotNodeOptions
|
||||||
|
|
||||||
// C_botNodeConfig is the global singleton mirroring bot_node_config.json,
|
// NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
|
||||||
// populated by LoadBotNodeConfig.
|
// The map is rebuilt rather than mutated on every load, so the pointer can be
|
||||||
var C_botNodeConfig BotNodeMembers
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors reported by the incoming webhook endpoint helpers. The HTTP layer maps
|
||||||
|
// them onto statuses: exists -> 409, not found -> 404, invalid -> 400.
|
||||||
|
var (
|
||||||
|
// ErrWebhookEndpointExists is returned by AddWebhookEndpoint when the name
|
||||||
|
// is already configured.
|
||||||
|
ErrWebhookEndpointExists = errors.New("webhook endpoint already exists")
|
||||||
|
|
||||||
|
// ErrWebhookEndpointNotFound is returned when the named endpoint is not
|
||||||
|
// configured.
|
||||||
|
ErrWebhookEndpointNotFound = errors.New("webhook endpoint not found")
|
||||||
|
|
||||||
|
// ErrWebhookEndpointInvalid is returned when a name or field supplied for an
|
||||||
|
// endpoint cannot be stored.
|
||||||
|
ErrWebhookEndpointInvalid = errors.New("invalid webhook endpoint")
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhookEndpointFields are the endpoint keys a client may set. A field that is
|
||||||
|
// absent from an update is left untouched; a field that is present but not
|
||||||
|
// listed here is rejected rather than written, so a typo cannot leave an
|
||||||
|
// endpoint silently ignoring a setting.
|
||||||
|
var webhookEndpointFields = map[string]func(interface{}) bool{
|
||||||
|
"enabled": isJSONBool,
|
||||||
|
"token": isJSONString,
|
||||||
|
"notifyPipes": isJSONStringArray,
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookEndpoints returns the configured incoming webhook endpoints keyed by
|
||||||
|
// name, as a copy: changing the result does not change the loaded
|
||||||
|
// configuration.
|
||||||
|
func WebhookEndpoints() map[string]WebhookEndpointConfig {
|
||||||
|
endpoints := map[string]WebhookEndpointConfig{}
|
||||||
|
cfg := Current()
|
||||||
|
if cfg == nil {
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
for name, endpoint := range cfg.Webhook.Endpoints {
|
||||||
|
endpoints[name] = endpoint
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddWebhookEndpoint registers a new incoming webhook endpoint under name. Only
|
||||||
|
// the fields present in fields are set, so an endpoint can be created with
|
||||||
|
// default values and completed later by ModifyWebhookEndpoint. Unlike
|
||||||
|
// ModifyWebhookEndpoint it refuses to touch an endpoint that already exists.
|
||||||
|
func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||||
|
if err := validateWebhookEndpointName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
patch, err := webhookEndpointPatch(fields)
|
||||||
|
if err != nil {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
||||||
|
// fields present in fields are changed; every other field keeps its configured
|
||||||
|
// value.
|
||||||
|
func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||||
|
if err := validateWebhookEndpointName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
patch, err := webhookEndpointPatch(fields)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(patch) == 0 {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
||||||
|
cfg := Current()
|
||||||
|
if cfg == nil {
|
||||||
|
return WebhookEndpointConfig{}, false
|
||||||
|
}
|
||||||
|
endpoint, exists := cfg.Webhook.Endpoints[name]
|
||||||
|
return endpoint, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookEndpointsPatch wraps the fields of one endpoint into the nested patch
|
||||||
|
// the settings merge expects for webhook.endpoints.<name>.
|
||||||
|
func webhookEndpointsPatch(name string, fields map[string]interface{}) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"webhook": map[string]interface{}{
|
||||||
|
"endpoints": map[string]interface{}{name: fields},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookEndpointDeletePatch is the patch that removes an endpoint. The value is
|
||||||
|
// a null, which the settings merge reads as "delete this key". It must be an
|
||||||
|
// untyped nil: a nil map of type map[string]interface{} would be merged as an
|
||||||
|
// empty object instead, leaving the endpoint in the configuration.
|
||||||
|
func webhookEndpointDeletePatch(name string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"webhook": map[string]interface{}{
|
||||||
|
"endpoints": map[string]interface{}{name: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookEndpointPatch validates the fields of one endpoint and returns them as
|
||||||
|
// the value to merge. Fields not accepted for an endpoint are rejected instead
|
||||||
|
// of being written to the configuration file.
|
||||||
|
func webhookEndpointPatch(fields map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
patch := make(map[string]interface{}, len(fields))
|
||||||
|
for key, value := range fields {
|
||||||
|
accepts, known := webhookEndpointFields[key]
|
||||||
|
if !known {
|
||||||
|
return nil, fmt.Errorf("%w: unknown field %q", ErrWebhookEndpointInvalid, key)
|
||||||
|
}
|
||||||
|
if !accepts(value) {
|
||||||
|
return nil, fmt.Errorf("%w: field %q has the wrong type", ErrWebhookEndpointInvalid, key)
|
||||||
|
}
|
||||||
|
patch[key] = value
|
||||||
|
}
|
||||||
|
return patch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWebhookEndpointName checks that a name can address an endpoint. The
|
||||||
|
// name is the last segment of the endpoint URL, so a name containing a slash
|
||||||
|
// could never be reached.
|
||||||
|
func validateWebhookEndpointName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("%w: name must not be empty", ErrWebhookEndpointInvalid)
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
return fmt.Errorf("%w: name must not contain %q", ErrWebhookEndpointInvalid, "/")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The predicates below accept the decoded JSON types a field may carry. Numbers
|
||||||
|
// decoded with UseNumber stay json.Number, so a JSON true/false is the only
|
||||||
|
// value accepted for a boolean field.
|
||||||
|
func isJSONBool(value interface{}) bool {
|
||||||
|
_, ok := value.(bool)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSONString(value interface{}) bool {
|
||||||
|
_, ok := value.(string)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSONStringArray(value interface{}) bool {
|
||||||
|
items, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if _, ok := item.(string); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+2
-2
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
|
|||||||
## Features
|
## 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).
|
- **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, plus the per-node *status notify* switch 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. 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.
|
||||||
- **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`.
|
- **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.
|
- **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.
|
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
|
||||||
@@ -58,5 +58,5 @@ frontend/
|
|||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
- The backend's `/api/system/getLogs` websocket is currently unauthenticated — anyone who can reach the port can read logs. Consider gating it in a future backend change.
|
- The log websocket needs an `admin` token, and a browser cannot set headers on a WebSocket handshake, so the token rides in the query string (`/api/system/getLogs?token=…×tamp=…`). That URL is a credential: it can end up in proxy and access logs, so don't paste it into third-party tools. The view reconnects with a fresh token from `localStorage` on every attempt.
|
||||||
- Registering more than one user is intentionally impossible; the backend only accepts the very first registration.
|
- Registering more than one user is intentionally impossible; the backend only accepts the very first registration.
|
||||||
Generated
-3
@@ -1473,7 +1473,6 @@
|
|||||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1596,7 +1595,6 @@
|
|||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -1671,7 +1669,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
|
||||||
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
|
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.42",
|
"@vue/compiler-dom": "3.5.42",
|
||||||
"@vue/compiler-sfc": "3.5.42",
|
"@vue/compiler-sfc": "3.5.42",
|
||||||
|
|||||||
@@ -13,9 +13,26 @@ export const serverApi = {
|
|||||||
|
|
||||||
// /api/settings/get?type=… / /api/settings/set?type=…
|
// /api/settings/get?type=… / /api/settings/set?type=…
|
||||||
// get → { success, message, data: { config } }.
|
// 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.
|
// `type` is one of global | bot_user_config | bot_node_config.
|
||||||
// For set, pass a partial object; a JSON null value removes that key.
|
// For set, pass a partial object; a JSON null value removes that key.
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: (type) => http.get(`/settings/get?type=${encodeURIComponent(type)}`),
|
get: (type) => http.get(`/settings/get?type=${encodeURIComponent(type)}`),
|
||||||
set: (type, patch) => http.post(`/settings/set?type=${encodeURIComponent(type)}`, patch)
|
set: (type, patch) => http.post(`/settings/set?type=${encodeURIComponent(type)}`, patch)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Incoming webhook endpoints (admin). They live in the `webhook.endpoints`
|
||||||
|
// section of config.json, but are managed here rather than through the settings
|
||||||
|
// API because they are a keyed collection: add/modify take one endpoint object
|
||||||
|
// and change only the fields they carry, and a new endpoint is rejected with
|
||||||
|
// 409 when its name is taken.
|
||||||
|
// list → { success, message, data: { endpoints: { "<name>": { enabled, token, notifyPipes } } } }.
|
||||||
|
export const webhookApi = {
|
||||||
|
list: () => http.get('/webhook/list'),
|
||||||
|
add: (endpoint) => http.post('/webhook/add', endpoint),
|
||||||
|
modify: (endpoint) => http.post('/webhook/modify', endpoint),
|
||||||
|
remove: (name) => http.post('/webhook/delete', { name })
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue';
|
||||||
|
import { settingsApi } from '../api/index.js';
|
||||||
|
import { debugMode } from '../utils/runtime.js';
|
||||||
|
import { toast } from '../utils/toast.js';
|
||||||
|
import Toggle from './Toggle.vue';
|
||||||
|
import TagsEditor from './TagsEditor.vue';
|
||||||
|
import HeadersEditor from './HeadersEditor.vue';
|
||||||
|
|
||||||
|
// One card of the global config.json: it renders the fields a section
|
||||||
|
// descriptor declares and saves exactly those fields, leaving every other key
|
||||||
|
// of the file untouched. Settings.vue and WebHooks.vue both compose this
|
||||||
|
// component, which is why the descriptor (not the config layout) is what a view
|
||||||
|
// supplies here.
|
||||||
|
//
|
||||||
|
// A descriptor is:
|
||||||
|
// id unique key of the section, used for logging
|
||||||
|
// title card heading
|
||||||
|
// hint optional line under the heading
|
||||||
|
// root path in config.json the fields live under, e.g. ['controllerMethod', 'ntfy']
|
||||||
|
// fields [{ key, type, label, ... }], where type is one of
|
||||||
|
// bool | text | password | number | select | textarea | tags | headers
|
||||||
|
// - `lp` overrides the field key with an explicit path inside root
|
||||||
|
// - `options` lists the choices of a select, `placeholder`/`help` are
|
||||||
|
// passed through to the input
|
||||||
|
const props = defineProps({
|
||||||
|
section: { type: Object, required: true },
|
||||||
|
config: { type: Object, default: () => ({}) }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved']);
|
||||||
|
|
||||||
|
const vals = ref({});
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
function fieldPath(f) {
|
||||||
|
return f.lp || [f.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVal(obj, path, fb) {
|
||||||
|
let cur = obj;
|
||||||
|
for (const k of path) {
|
||||||
|
if (cur === null || cur === undefined || typeof cur !== 'object') return fb;
|
||||||
|
cur = cur[k];
|
||||||
|
}
|
||||||
|
return cur === undefined || cur === null ? fb : cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasVal reports whether a path actually resolves in the loaded config. A
|
||||||
|
// missing key and a key whose value equals the fallback are indistinguishable
|
||||||
|
// from getVal's return value alone, so misses are detected separately.
|
||||||
|
function hasVal(obj, path) {
|
||||||
|
let cur = obj;
|
||||||
|
for (const k of path) {
|
||||||
|
if (cur === null || cur === undefined || typeof cur !== 'object') return false;
|
||||||
|
cur = cur[k];
|
||||||
|
}
|
||||||
|
return cur !== undefined && cur !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaults(f) {
|
||||||
|
switch (f.type) {
|
||||||
|
case 'bool': return false;
|
||||||
|
case 'number': return 0;
|
||||||
|
case 'tags': return [];
|
||||||
|
case 'headers': return {};
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror wrapRoot() on save: prepend the section's root path so a value is read
|
||||||
|
// from the same place it is written to.
|
||||||
|
function read() {
|
||||||
|
const obj = {};
|
||||||
|
for (const f of props.section.fields) {
|
||||||
|
const path = [...props.section.root, ...fieldPath(f)];
|
||||||
|
obj[f.key] = getVal(props.config, path, defaults(f));
|
||||||
|
if (debugMode.value) {
|
||||||
|
console.log(`[ConfigSection] Loaded ${props.section.id}.${f.key}:`, obj[f.key]);
|
||||||
|
if (!hasVal(props.config, path)) {
|
||||||
|
console.warn(`[ConfigSection] ${props.section.id}.${f.key} missing at "${path.join('.')}" — using default`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vals.value = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(f, v) {
|
||||||
|
switch (f.type) {
|
||||||
|
case 'number': {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
case 'tags': return Array.isArray(v) ? v : [];
|
||||||
|
case 'headers': return v && typeof v === 'object' ? v : {};
|
||||||
|
default: return v === null || v === undefined ? '' : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nest(obj) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const path = k.split('.');
|
||||||
|
let o = out;
|
||||||
|
for (let i = 0; i < path.length - 1; i += 1) {
|
||||||
|
const seg = path[i];
|
||||||
|
if (!o[seg]) o[seg] = {};
|
||||||
|
o = o[seg];
|
||||||
|
}
|
||||||
|
o[path[path.length - 1]] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapRoot(section, obj) {
|
||||||
|
const root = section.root;
|
||||||
|
if (!root.length) return obj;
|
||||||
|
const out = {};
|
||||||
|
let o = out;
|
||||||
|
for (let i = 0; i < root.length - 1; i += 1) {
|
||||||
|
o[root[i]] = {};
|
||||||
|
o = o[root[i]];
|
||||||
|
}
|
||||||
|
o[root[root.length - 1]] = obj;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const obj = {};
|
||||||
|
for (const f of props.section.fields) {
|
||||||
|
obj[f.key] = normalize(f, vals.value[f.key]);
|
||||||
|
}
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
emit('saved');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Failed to save: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read whenever the caller reloads the configuration, so the card always
|
||||||
|
// shows what the file holds.
|
||||||
|
watch(() => props.config, read, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ section.title }}</h3>
|
||||||
|
<p v-if="section.hint" class="hint">{{ section.hint }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
|
||||||
|
<span v-if="saving" class="spinner" style="width:12px;height:12px" />
|
||||||
|
<i v-else class="fas fa-check" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<template v-for="f in section.fields" :key="f.key">
|
||||||
|
<div v-if="f.type === 'bool'" class="bool-cell">
|
||||||
|
<Toggle :model-value="vals[f.key]" :label="f.label" @update:model-value="vals[f.key] = $event" />
|
||||||
|
<p v-if="f.help" class="field-help">{{ f.help }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="f.type === 'tags'" class="field span-2">
|
||||||
|
<label>{{ f.label }}</label>
|
||||||
|
<TagsEditor v-model="vals[f.key]" :placeholder="f.placeholder" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="f.type === 'headers'" class="field span-2">
|
||||||
|
<label>{{ f.label }}</label>
|
||||||
|
<HeadersEditor v-model="vals[f.key]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="field span-2">
|
||||||
|
<label>{{ f.label }}</label>
|
||||||
|
<textarea
|
||||||
|
v-if="f.type === 'textarea'"
|
||||||
|
v-model="vals[f.key]"
|
||||||
|
class="textarea"
|
||||||
|
rows="4"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-else-if="f.type === 'select'"
|
||||||
|
v-model="vals[f.key]"
|
||||||
|
class="select"
|
||||||
|
>
|
||||||
|
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="vals[f.key]"
|
||||||
|
class="input"
|
||||||
|
:type="f.type === 'password' ? 'password' : 'text'"
|
||||||
|
:placeholder="f.placeholder || ''"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bool-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding-left: 50px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,7 @@ const groups = [
|
|||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Settings', to: '/settings', title: 'Settings', icon: 'fa-sliders' },
|
{ name: 'Settings', to: '/settings', title: 'Settings', icon: 'fa-sliders' },
|
||||||
|
{ name: 'WebHooks', to: '/webhooks', title: 'WebHooks', icon: 'fa-satellite-dish' },
|
||||||
{ name: 'Logs', to: '/logs', title: 'Logs', icon: 'fa-terminal' }
|
{ name: 'Logs', to: '/logs', title: 'Logs', icon: 'fa-terminal' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const routes = [
|
|||||||
component: () => import('../views/Settings.vue'),
|
component: () => import('../views/Settings.vue'),
|
||||||
meta: { title: 'Settings' }
|
meta: { title: 'Settings' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'webhooks',
|
||||||
|
name: 'WebHooks',
|
||||||
|
component: () => import('../views/WebHooks.vue'),
|
||||||
|
meta: { title: 'WebHooks' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
name: 'Logs',
|
name: 'Logs',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { getToken } from '../utils/auth.js';
|
||||||
import { LOG_LEVELS } from '../utils/fmt.js';
|
import { LOG_LEVELS } from '../utils/fmt.js';
|
||||||
|
|
||||||
const MAX_LOGS = 1200;
|
const MAX_LOGS = 1200;
|
||||||
@@ -38,7 +39,14 @@ const statusText = computed(() => {
|
|||||||
|
|
||||||
function wsUrl() {
|
function wsUrl() {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return `${proto}//${window.location.host}/api/system/getLogs`;
|
// The backend only upgrades the request for an admin token. A browser cannot
|
||||||
|
// set headers on a WebSocket handshake, so the credentials travel in the
|
||||||
|
// query string — the URL itself is therefore a secret.
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: getToken(),
|
||||||
|
timestamp: String(Math.floor(Date.now() / 1000))
|
||||||
|
});
|
||||||
|
return `${proto}//${window.location.host}/api/system/getLogs?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -49,6 +57,11 @@ function connect() {
|
|||||||
if (ws) {
|
if (ws) {
|
||||||
try { ws.close(); } catch { /* ignore */ }
|
try { ws.close(); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
// Signed out: the handshake would be rejected, so don't spin on reconnects.
|
||||||
|
if (!getToken()) {
|
||||||
|
status.value = 'closed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
status.value = 'connecting';
|
status.value = 'connecting';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+156
-20
@@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js';
|
|||||||
import { toast } from '../utils/toast.js';
|
import { toast } from '../utils/toast.js';
|
||||||
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
||||||
import Toggle from '../components/Toggle.vue';
|
import Toggle from '../components/Toggle.vue';
|
||||||
|
import Modal from '../components/Modal.vue';
|
||||||
|
|
||||||
function percent(m) {
|
function percent(m) {
|
||||||
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
||||||
@@ -13,9 +14,20 @@ const query = ref('');
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
const entries = ref([]);
|
const entries = ref([]);
|
||||||
const saving = reactive(new Set());
|
|
||||||
let timer = null;
|
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 filtered = computed(() => {
|
||||||
const q = query.value.trim().toLowerCase();
|
const q = query.value.trim().toLowerCase();
|
||||||
if (!q) return entries.value;
|
if (!q) return entries.value;
|
||||||
@@ -57,7 +69,8 @@ async function load() {
|
|||||||
online: !!s.online,
|
online: !!s.online,
|
||||||
report: s.report || null,
|
report: s.report || null,
|
||||||
info: infoEntry.info || null,
|
info: infoEntry.info || null,
|
||||||
notify: conf.enableStatusNotify !== false
|
notify: conf.enableStatusNotify !== false,
|
||||||
|
flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
|
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
|
||||||
@@ -71,21 +84,42 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleNotify(entry) {
|
function openOptions(entry) {
|
||||||
const next = !entry.notify;
|
editing.uuid = entry.uuid;
|
||||||
const prev = entry.notify;
|
editing.name = entry.name;
|
||||||
entry.notify = next;
|
editing.notify = entry.notify;
|
||||||
saving.add(entry.uuid);
|
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;
|
||||||
try {
|
try {
|
||||||
const patch = {};
|
const patch = {};
|
||||||
patch[entry.uuid] = { enableStatusNotify: next };
|
patch[uuid] = {
|
||||||
|
enableStatusNotify: editing.notify,
|
||||||
|
flap_tolerant: seconds > 0 ? seconds : null
|
||||||
|
};
|
||||||
await settingsApi.set('bot_node_config', patch);
|
await settingsApi.set('bot_node_config', patch);
|
||||||
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
|
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;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
entry.notify = prev;
|
|
||||||
toast.error('Save failed: ' + e.message);
|
toast.error('Save failed: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
saving.delete(entry.uuid);
|
editing.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +145,6 @@ onBeforeUnmount(() => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Overview</p>
|
<p class="eyebrow">Overview</p>
|
||||||
<h1>Nodes</h1>
|
<h1>Nodes</h1>
|
||||||
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
||||||
@@ -207,17 +240,60 @@ onBeforeUnmount(() => {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="notify">
|
<div class="opts">
|
||||||
<Toggle
|
<span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
|
||||||
:model-value="e.notify"
|
<i class="fas fa-bell-slash" /> Notify off
|
||||||
label="Status notify"
|
</span>
|
||||||
:disabled="saving.has(e.uuid)"
|
<span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
|
||||||
@update:model-value="toggleNotify(e)"
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -298,7 +374,67 @@ onBeforeUnmount(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify { display: flex; align-items: center; }
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-hint {
|
.refresh-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+27
-207
@@ -1,17 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { settingsApi } from '../api/index.js';
|
import { settingsApi } from '../api/index.js';
|
||||||
import { debugMode } from '../utils/runtime.js';
|
|
||||||
import { toast } from '../utils/toast.js';
|
import { toast } from '../utils/toast.js';
|
||||||
import Toggle from '../components/Toggle.vue';
|
import ConfigSection from '../components/ConfigSection.vue';
|
||||||
import TagsEditor from '../components/TagsEditor.vue';
|
|
||||||
import HeadersEditor from '../components/HeadersEditor.vue';
|
|
||||||
|
|
||||||
|
// Every section is rendered and saved by ConfigSection, which owns the
|
||||||
|
// field-descriptor format. The incoming webhook API has its own page (see
|
||||||
|
// WebHooks.vue) and is not listed here.
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
id: 'system',
|
id: 'system',
|
||||||
title: 'System',
|
title: 'System',
|
||||||
hint: 'HTTP listener and global runtime switches.',
|
hint: 'HTTP listener and global runtime switches. A changed listen address or port applies on restart.',
|
||||||
root: ['system'],
|
root: ['system'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
|
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
|
||||||
@@ -23,21 +23,22 @@ const sections = [
|
|||||||
{
|
{
|
||||||
id: 'debug',
|
id: 'debug',
|
||||||
title: 'Debug logging',
|
title: 'Debug logging',
|
||||||
hint: 'Per-module verbosity for the bot pipes.',
|
hint: '',
|
||||||
root: ['debug'],
|
root: ['debug'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
|
{ key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' },
|
||||||
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
|
{ key: 'showOneBotAction', type: 'bool', label: 'OneBot actions' },
|
||||||
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
||||||
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
||||||
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
||||||
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
|
{ key: 'showFlapTolerantEcho', type: 'bool', label: 'Flap inhibition echo' },
|
||||||
|
{ key: 'onebotIgnoreSelfMsg', type: 'bool', label: 'Ignore OneBot self messages' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'komari',
|
id: 'komari',
|
||||||
title: 'Komari dashboard',
|
title: 'Komari dashboard',
|
||||||
hint: 'Connection the monitor reads node data from. Takes effect on restart.',
|
hint: 'Connection the monitor reads node data from. The URL applies on restart; the account is re-read on the next login.',
|
||||||
root: ['komari'],
|
root: ['komari'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
|
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
|
||||||
@@ -60,15 +61,16 @@ const sections = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qq',
|
id: 'qq_onebot',
|
||||||
title: 'QQ controller (NapCat)',
|
title: 'QQ controller (OneBot)',
|
||||||
root: ['controllerMethod', 'qq(napcat)'],
|
root: ['controllerMethod', 'qq_onebot'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
{ 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: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||||
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
{ key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
|
||||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
{ key: 'oneBotPort', type: 'text', label: 'OneBot port' },
|
||||||
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
|
{ key: 'oneBotToken', type: 'password', label: 'OneBot token' },
|
||||||
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
||||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||||
]
|
]
|
||||||
@@ -79,6 +81,7 @@ const sections = [
|
|||||||
root: ['controllerMethod', 'telegram'],
|
root: ['controllerMethod', 'telegram'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||||
|
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||||
{ key: 'botToken', type: 'password', label: 'Bot token' },
|
{ key: 'botToken', type: 'password', label: 'Bot token' },
|
||||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||||
@@ -90,6 +93,7 @@ const sections = [
|
|||||||
root: ['controllerMethod', 'email'],
|
root: ['controllerMethod', 'email'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||||
|
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||||
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
||||||
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
||||||
@@ -106,6 +110,7 @@ const sections = [
|
|||||||
root: ['controllerMethod', 'ntfy'],
|
root: ['controllerMethod', 'ntfy'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||||
|
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||||
{ key: 'server', type: 'text', label: 'Server' },
|
{ key: 'server', type: 'text', label: 'Server' },
|
||||||
{ key: 'topic', type: 'text', label: 'Topic' },
|
{ key: 'topic', type: 'text', label: 'Topic' },
|
||||||
@@ -116,9 +121,11 @@ const sections = [
|
|||||||
{
|
{
|
||||||
id: 'webhook',
|
id: 'webhook',
|
||||||
title: 'Webhook notifications',
|
title: 'Webhook notifications',
|
||||||
|
hint: 'Where this program posts its own alerts. The incoming webhook API has its own page (see WebHooks).',
|
||||||
root: ['controllerMethod', 'webhook'],
|
root: ['controllerMethod', 'webhook'],
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||||
|
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||||
{ key: 'url', type: 'text', label: 'URL' },
|
{ key: 'url', type: 'text', label: 'URL' },
|
||||||
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||||
@@ -140,64 +147,12 @@ const sections = [
|
|||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
const vals = reactive({});
|
const cfg = ref({});
|
||||||
const saving = reactive(new Set());
|
|
||||||
|
|
||||||
function fieldPath(f) {
|
|
||||||
return f.lp || [f.key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVal(obj, path, fb) {
|
|
||||||
let cur = obj;
|
|
||||||
for (const k of path) {
|
|
||||||
if (cur === null || cur === undefined || typeof cur !== 'object') return fb;
|
|
||||||
cur = cur[k];
|
|
||||||
}
|
|
||||||
return cur === undefined || cur === null ? fb : cur;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasVal reports whether a path actually resolves in the loaded config. A
|
|
||||||
// missing key and a key whose value equals the fallback are indistinguishable
|
|
||||||
// from getVal's return value alone, so misses are detected separately.
|
|
||||||
function hasVal(obj, path) {
|
|
||||||
let cur = obj;
|
|
||||||
for (const k of path) {
|
|
||||||
if (cur === null || cur === undefined || typeof cur !== 'object') return false;
|
|
||||||
cur = cur[k];
|
|
||||||
}
|
|
||||||
return cur !== undefined && cur !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaults(f) {
|
|
||||||
switch (f.type) {
|
|
||||||
case 'bool': return false;
|
|
||||||
case 'number': return 0;
|
|
||||||
case 'tags': return [];
|
|
||||||
case 'headers': return {};
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await settingsApi.get('global');
|
const res = await settingsApi.get('global');
|
||||||
const cfg = (res && res.data && res.data.config) || {};
|
cfg.value = (res && res.data && res.data.config) || {};
|
||||||
for (const s of sections) {
|
|
||||||
const obj = {};
|
|
||||||
for (const f of s.fields) {
|
|
||||||
// Mirror wrapRoot() on save: prepend the section's root path so
|
|
||||||
// the value is read from the same place it is written to.
|
|
||||||
const path = [...s.root, ...fieldPath(f)];
|
|
||||||
obj[f.key] = getVal(cfg, path, defaults(f));
|
|
||||||
if (debugMode.value) {
|
|
||||||
console.log(`[Settings] Loaded ${s.id}.${f.key}:`, obj[f.key]);
|
|
||||||
if (!hasVal(cfg, path)) {
|
|
||||||
console.warn(`[Settings] ${s.id}.${f.key} missing at "${path.join('.')}" — using default`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vals[s.id] = obj;
|
|
||||||
}
|
|
||||||
failed.value = false;
|
failed.value = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failed.value = true;
|
failed.value = true;
|
||||||
@@ -207,65 +162,6 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(f, v) {
|
|
||||||
switch (f.type) {
|
|
||||||
case 'number': {
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? n : 0;
|
|
||||||
}
|
|
||||||
case 'tags': return Array.isArray(v) ? v : [];
|
|
||||||
case 'headers': return v && typeof v === 'object' ? v : {};
|
|
||||||
default: return v === null || v === undefined ? '' : v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nest(obj) {
|
|
||||||
const out = {};
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
const path = k.split('.');
|
|
||||||
let o = out;
|
|
||||||
for (let i = 0; i < path.length - 1; i += 1) {
|
|
||||||
const seg = path[i];
|
|
||||||
if (!o[seg]) o[seg] = {};
|
|
||||||
o = o[seg];
|
|
||||||
}
|
|
||||||
o[path[path.length - 1]] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapRoot(section, obj) {
|
|
||||||
const root = section.root;
|
|
||||||
if (!root.length) return obj;
|
|
||||||
const out = {};
|
|
||||||
let o = out;
|
|
||||||
for (let i = 0; i < root.length - 1; i += 1) {
|
|
||||||
o[root[i]] = {};
|
|
||||||
o = o[root[i]];
|
|
||||||
}
|
|
||||||
o[root[root.length - 1]] = obj;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(section) {
|
|
||||||
const source = vals[section.id];
|
|
||||||
const obj = {};
|
|
||||||
for (const f of section.fields) {
|
|
||||||
obj[f.key] = normalize(f, source[f.key]);
|
|
||||||
}
|
|
||||||
const patch = wrapRoot(section, nest(obj));
|
|
||||||
saving.add(section.id);
|
|
||||||
try {
|
|
||||||
await settingsApi.set('global', patch);
|
|
||||||
toast.success(`${section.title} saved`);
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('Failed to save: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
saving.delete(section.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -275,7 +171,6 @@ onMounted(load);
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Configuration</p>
|
<p class="eyebrow">Configuration</p>
|
||||||
<h1>System settings</h1>
|
<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>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
||||||
@@ -291,82 +186,7 @@ onMounted(load);
|
|||||||
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="s in sections" :key="s.id" class="card">
|
<ConfigSection v-for="s in sections" :key="s.id" :section="s" :config="cfg" @saved="load" />
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h3>{{ s.title }}</h3>
|
|
||||||
<p v-if="s.hint" class="hint">{{ s.hint }}</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" :disabled="saving.has(s.id)" @click="save(s)">
|
|
||||||
<span v-if="saving.has(s.id)" class="spinner" style="width:12px;height:12px" />
|
|
||||||
<i v-else class="fas fa-check" /> Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-grid">
|
|
||||||
<template v-for="f in s.fields" :key="f.key">
|
|
||||||
<div v-if="f.type === 'bool'" class="bool-cell">
|
|
||||||
<Toggle :model-value="vals[s.id][f.key]" :label="f.label" @update:model-value="vals[s.id][f.key] = $event" />
|
|
||||||
<p v-if="f.help" class="field-help">{{ f.help }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="f.type === 'tags'" class="field span-2">
|
|
||||||
<label>{{ f.label }}</label>
|
|
||||||
<TagsEditor v-model="vals[s.id][f.key]" :placeholder="f.placeholder" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="f.type === 'headers'" class="field span-2">
|
|
||||||
<label>{{ f.label }}</label>
|
|
||||||
<HeadersEditor v-model="vals[s.id][f.key]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="field span-2">
|
|
||||||
<label>{{ f.label }}</label>
|
|
||||||
<textarea
|
|
||||||
v-if="f.type === 'textarea'"
|
|
||||||
v-model="vals[s.id][f.key]"
|
|
||||||
class="textarea"
|
|
||||||
rows="4"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
v-else-if="f.type === 'select'"
|
|
||||||
v-model="vals[s.id][f.key]"
|
|
||||||
class="select"
|
|
||||||
>
|
|
||||||
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
v-model="vals[s.id][f.key]"
|
|
||||||
class="input"
|
|
||||||
:type="f.type === 'password' ? 'password' : 'text'"
|
|
||||||
:placeholder="f.placeholder || ''"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bool-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-help {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-3);
|
|
||||||
padding-left: 50px;
|
|
||||||
max-width: 340px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
|
|||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
|
|
||||||
const CHANNELS = [
|
const CHANNELS = [
|
||||||
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
|
{ key: 'qq_onebot', label: 'QQ (OneBot)', hint: 'Members are identified by QQ number / group number' },
|
||||||
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
|
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { settingsApi, webhookApi } from '../api/index.js';
|
||||||
|
import { toast } from '../utils/toast.js';
|
||||||
|
import ConfigSection from '../components/ConfigSection.vue';
|
||||||
|
import Toggle from '../components/Toggle.vue';
|
||||||
|
import Modal from '../components/Modal.vue';
|
||||||
|
|
||||||
|
// The notification channels an endpoint may relay to. These mirror the
|
||||||
|
// controller names in config.json (controllerMethod.*), which is exactly what
|
||||||
|
// the backend matches notifyPipes against.
|
||||||
|
const CHANNELS = [
|
||||||
|
{ key: 'qq_onebot', label: 'QQ_OneBot' },
|
||||||
|
{ key: 'telegram', label: 'Telegram' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'ntfy', label: 'ntfy' },
|
||||||
|
{ key: 'webhook', label: 'WebHook' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// The listener half of the incoming webhook API. The endpoints it serves are
|
||||||
|
// managed through /api/webhook/* instead of the settings API, because they are
|
||||||
|
// a keyed collection rather than a fixed set of fields. The outgoing WebHook
|
||||||
|
// notification channel stays on the Settings page with the other channels.
|
||||||
|
const apiSection = {
|
||||||
|
id: 'webhookApi',
|
||||||
|
title: 'Incoming API',
|
||||||
|
hint: 'Listener external applications post to. The address and port are read at startup — changing them needs a restart.',
|
||||||
|
root: ['webhook'],
|
||||||
|
fields: [
|
||||||
|
{ key: 'enabled', type: 'bool', label: 'Enabled', help: 'Disabled means no listener is started at all.' },
|
||||||
|
{ key: 'listenAddr', type: 'text', label: 'Listen address' },
|
||||||
|
{ key: 'listenPort', type: 'text', label: 'Listen port' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const failed = ref(false);
|
||||||
|
const cfg = ref({});
|
||||||
|
const endpoints = ref([]);
|
||||||
|
const saving = reactive(new Set());
|
||||||
|
const revealed = reactive(new Set());
|
||||||
|
|
||||||
|
const editor = reactive({ open: false, mode: 'add', name: '', token: '', pipes: [] });
|
||||||
|
const removing = reactive({ open: false, name: '' });
|
||||||
|
|
||||||
|
const channelLabel = (key) => (CHANNELS.find((c) => c.key === key) || { label: key }).label;
|
||||||
|
|
||||||
|
// Endpoint URLs are on the webhook listener, not the one serving this console,
|
||||||
|
// so the host is taken from the browser and the port from the configuration.
|
||||||
|
const listenPort = computed(() => (cfg.value.webhook && cfg.value.webhook.listenPort) || '8081');
|
||||||
|
const endpointURL = (name) => `http://${window.location.hostname}:${listenPort.value}/api/webhook/post/${name}`;
|
||||||
|
|
||||||
|
function normalizeEndpoint(name, e) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
enabled: e.enabled === true,
|
||||||
|
token: typeof e.token === 'string' ? e.token : '',
|
||||||
|
notifyPipes: Array.isArray(e.notifyPipes) ? e.notifyPipes : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [settings, list] = await Promise.all([settingsApi.get('global'), webhookApi.list()]);
|
||||||
|
cfg.value = (settings && settings.data && settings.data.config) || {};
|
||||||
|
const map = (list && list.data && list.data.endpoints) || {};
|
||||||
|
endpoints.value = Object.entries(map)
|
||||||
|
.map(([name, e]) => normalizeEndpoint(name, e))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
failed.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
failed.value = true;
|
||||||
|
if (e.status) toast.error('Failed to load WebHooks: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEndpoint(name, fields, okMsg) {
|
||||||
|
saving.add(name);
|
||||||
|
try {
|
||||||
|
await webhookApi.modify({ name, ...fields });
|
||||||
|
if (okMsg) toast.success(okMsg);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Save failed: ' + e.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
saving.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(row, value) {
|
||||||
|
const prev = row.enabled;
|
||||||
|
row.enabled = value;
|
||||||
|
if (!(await saveEndpoint(row.name, { enabled: value }))) {
|
||||||
|
row.enabled = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
editor.mode = 'add';
|
||||||
|
editor.name = '';
|
||||||
|
editor.token = generateToken();
|
||||||
|
editor.pipes = [];
|
||||||
|
editor.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
editor.mode = 'edit';
|
||||||
|
editor.name = row.name;
|
||||||
|
editor.token = row.token;
|
||||||
|
editor.pipes = [...row.notifyPipes];
|
||||||
|
editor.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePipe(key) {
|
||||||
|
const at = editor.pipes.indexOf(key);
|
||||||
|
if (at === -1) editor.pipes.push(key);
|
||||||
|
else editor.pipes.splice(at, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 30-character hex token, so a new endpoint never starts out with a guessable
|
||||||
|
// shared secret.
|
||||||
|
function generateToken() {
|
||||||
|
const bytes = new Uint8Array(15);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEditor() {
|
||||||
|
const name = editor.name.trim();
|
||||||
|
if (!name) return toast.warn('Enter a name');
|
||||||
|
if (name.includes('/')) return toast.warn('The name cannot contain "/"');
|
||||||
|
// The backend refuses requests to an endpoint that has no token, so an
|
||||||
|
// empty one would only ever answer 500.
|
||||||
|
if (!editor.token) return toast.warn('Enter or generate a token');
|
||||||
|
|
||||||
|
const body = { name, token: editor.token, notifyPipes: [...editor.pipes] };
|
||||||
|
try {
|
||||||
|
if (editor.mode === 'add') {
|
||||||
|
// A new endpoint starts enabled; the switch in the list turns it off.
|
||||||
|
await webhookApi.add({ ...body, enabled: true });
|
||||||
|
toast.success('Endpoint added');
|
||||||
|
} else {
|
||||||
|
// enabled is deliberately left out: the list switch owns it, and a
|
||||||
|
// value read when the editor opened could undo a toggle made since.
|
||||||
|
await webhookApi.modify(body);
|
||||||
|
toast.success('Endpoint updated');
|
||||||
|
}
|
||||||
|
editor.open = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Save failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemove(row) {
|
||||||
|
removing.name = row.name;
|
||||||
|
removing.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemove() {
|
||||||
|
try {
|
||||||
|
await webhookApi.remove(removing.name);
|
||||||
|
toast.success('Endpoint removed');
|
||||||
|
removing.open = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Remove failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(value, okMsg) {
|
||||||
|
if (!navigator.clipboard) return toast.warn('Clipboard unavailable — copy it manually');
|
||||||
|
navigator.clipboard.writeText(value).then(() => toast.success(okMsg), () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Integrations</p>
|
||||||
|
<h1>WebHooks</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Let external applications push alerts through this program and relay them to the notification channels.
|
||||||
|
Where this program posts its own alerts is configured on the Settings page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button class="btn btn-ghost" @click="load">
|
||||||
|
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="failed" class="card empty">
|
||||||
|
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||||
|
<p>Failed to load WebHook settings</p>
|
||||||
|
<button class="btn btn-primary" @click="load">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ConfigSection :section="apiSection" :config="cfg" @saved="load" />
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>Endpoints</h3>
|
||||||
|
<p class="hint">One endpoint per external application. Each carries its own token and relays to its own channels.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openAdd">
|
||||||
|
<i class="fas fa-plus" /> Add endpoint
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="endpoints.length === 0" class="empty" style="padding:28px">
|
||||||
|
No endpoints yet — add one to get an URL to post to
|
||||||
|
</div>
|
||||||
|
<div v-else class="opt-list">
|
||||||
|
<div v-for="e in endpoints" :key="e.name" class="ep-row">
|
||||||
|
<div class="ep-main">
|
||||||
|
<div class="ep-title">
|
||||||
|
<span class="id mono">{{ e.name }}</span>
|
||||||
|
<span class="badge" :class="e.enabled ? 'badge-online' : 'badge-offline'">
|
||||||
|
<span class="dot" />{{ e.enabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ep-line">
|
||||||
|
<span class="method mono">POST</span>
|
||||||
|
<span class="url mono">{{ endpointURL(e.name) }}</span>
|
||||||
|
<button class="icon-btn" title="Copy URL" @click="copy(endpointURL(e.name), 'Endpoint URL copied')">
|
||||||
|
<i class="fas fa-link" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ep-line">
|
||||||
|
<span class="lbl">Token</span>
|
||||||
|
<span class="url mono">{{ revealed.has(e.name) ? e.token : '••••••••••••' }}</span>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
:title="revealed.has(e.name) ? 'Hide token' : 'Show token'"
|
||||||
|
@click="revealed.has(e.name) ? revealed.delete(e.name) : revealed.add(e.name)"
|
||||||
|
>
|
||||||
|
<i class="fas" :class="revealed.has(e.name) ? 'fa-eye-slash' : 'fa-eye'" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="Copy token" @click="copy(e.token, 'Token copied')">
|
||||||
|
<i class="fas fa-copy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ep-channels">
|
||||||
|
<span v-if="e.notifyPipes.length === 0" class="badge badge-danger">
|
||||||
|
<span class="dot" />No channels
|
||||||
|
</span>
|
||||||
|
<span v-for="p in e.notifyPipes" :key="p" class="badge badge-accent">{{ channelLabel(p) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-actions">
|
||||||
|
<Toggle
|
||||||
|
:model-value="e.enabled"
|
||||||
|
:disabled="saving.has(e.name)"
|
||||||
|
@update:model-value="toggleEnabled(e, $event)"
|
||||||
|
/>
|
||||||
|
<button class="icon-btn" title="Edit" @click="openEdit(e)"><i class="fas fa-pen" /></button>
|
||||||
|
<button class="icon-btn danger" title="Remove" @click="askRemove(e)"><i class="fas fa-trash" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:open="editor.open"
|
||||||
|
:title="editor.mode === 'add' ? 'Add endpoint' : 'Edit endpoint'"
|
||||||
|
@close="editor.open = false"
|
||||||
|
>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field span-2">
|
||||||
|
<label>Name</label>
|
||||||
|
<input
|
||||||
|
v-model="editor.name"
|
||||||
|
class="input mono"
|
||||||
|
:disabled="editor.mode === 'edit'"
|
||||||
|
placeholder="example"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown.enter="confirmEditor"
|
||||||
|
/>
|
||||||
|
<p class="help">
|
||||||
|
Posted to <span class="mono">/api/webhook/post/<name></span>. The name cannot be changed afterwards.
|
||||||
|
</p>
|
||||||
|
<p v-if="editor.mode === 'add'" class="help">
|
||||||
|
The endpoint starts enabled — use the switch in the list to disable it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field span-2">
|
||||||
|
<label>Token</label>
|
||||||
|
<div class="token-row">
|
||||||
|
<input v-model="editor.token" class="input mono" spellcheck="false" autocomplete="off" />
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="editor.token = generateToken()">
|
||||||
|
<i class="fas fa-dice" /> Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="help">The caller sends this in the request body. An endpoint without a token rejects every request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field span-2">
|
||||||
|
<label>Relay to</label>
|
||||||
|
<div class="pipe-picker">
|
||||||
|
<button
|
||||||
|
v-for="c in CHANNELS"
|
||||||
|
:key="c.key"
|
||||||
|
type="button"
|
||||||
|
class="pipe"
|
||||||
|
:class="{ on: editor.pipes.includes(c.key) }"
|
||||||
|
@click="togglePipe(c.key)"
|
||||||
|
>
|
||||||
|
<i class="fas" :class="editor.pipes.includes(c.key) ? 'fa-square-check' : 'fa-square'" />
|
||||||
|
{{ c.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
The alert is relayed to every channel selected here; a channel that is disabled is skipped.
|
||||||
|
The WebHook channel's own destination is configured on the Settings page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #foot>
|
||||||
|
<button class="btn btn-ghost" @click="editor.open = false">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmEditor">
|
||||||
|
{{ editor.mode === 'add' ? 'Add' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal :open="removing.open" title="Remove endpoint" @close="removing.open = false">
|
||||||
|
<p style="line-height:1.6">
|
||||||
|
Remove <strong class="mono">{{ removing.name }}</strong>? Requests to its URL will stop being accepted.
|
||||||
|
</p>
|
||||||
|
<template #foot>
|
||||||
|
<button class="btn btn-ghost" @click="removing.open = false">Cancel</button>
|
||||||
|
<button class="btn btn-danger" @click="confirmRemove">Remove</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ep-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-title .id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-line .lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-line .url {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--r-s);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-channels {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-row .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipe-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipe {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipe:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipe.on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+2
-2
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
|
|||||||
|
|
||||||
var SoftwareInfo = SoftwareInfoStr{
|
var SoftwareInfo = SoftwareInfoStr{
|
||||||
Name: "Nukumizu",
|
Name: "Nukumizu",
|
||||||
Version: "0.1.2",
|
Version: "0.2.2",
|
||||||
Developer: "Madobi Nanami",
|
Developer: "Madobi Nanami",
|
||||||
BuildVer: 4,
|
BuildVer: 7,
|
||||||
CommitHash: "unknown",
|
CommitHash: "unknown",
|
||||||
Description: "Remote server monitoring and command execution subsystem for Komari",
|
Description: "Remote server monitoring and command execution subsystem for Komari",
|
||||||
BuildType: "pre-release",
|
BuildType: "pre-release",
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
result := template.Render("", params)
|
result := template.Render("", params, false)
|
||||||
|
|
||||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||||
"list": result,
|
"list": result,
|
||||||
|
|||||||
+16
-2
@@ -51,13 +51,27 @@ func setupAdminToken() {
|
|||||||
utils.AddToken("test-admin-token", 1, "admin", "tester")
|
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 {
|
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body map[string]json.RawMessage
|
var body struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data map[string]json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
||||||
}
|
}
|
||||||
return body
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerGetInfoAll(t *testing.T) {
|
func TestServerGetInfoAll(t *testing.T) {
|
||||||
|
|||||||
+8
-1
@@ -47,6 +47,10 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// {"system": {"debugMode": true}}
|
// {"system": {"debugMode": true}}
|
||||||
//
|
//
|
||||||
// Multiple entries may be given at once; only the provided keys are changed.
|
// 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) {
|
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !utils.Auth(w, r, "POST", "admin") {
|
if !utils.Auth(w, r, "POST", "admin") {
|
||||||
return
|
return
|
||||||
@@ -80,7 +84,10 @@ func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{}{
|
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
|
||||||
"type": settingsType,
|
"type": settingsType,
|
||||||
|
"restartRequired": config.IsRestartRequiredKeys(settingsType, patch),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/controller"
|
||||||
|
"nukumizu-backend/postLog"
|
||||||
|
"nukumizu-backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxWebhookBodyBytes caps the size of an incoming webhook request body. The
|
||||||
|
// endpoint is reachable without a session token, so the body is bounded before
|
||||||
|
// it is read.
|
||||||
|
const maxWebhookBodyBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
// webhookRequest is the JSON body accepted by the incoming webhook API.
|
||||||
|
type webhookRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/example
|
||||||
|
// {"token": "...", "subject": "...", "content": "..."}
|
||||||
|
//
|
||||||
|
// The alert is rendered per channel and sent through every channel the endpoint
|
||||||
|
// lists in notifyPipes. This route is not part of the token-authenticated API:
|
||||||
|
// it authenticates with the endpoint's own shared token.
|
||||||
|
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
utils.SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.PathValue("name")
|
||||||
|
endpoint, exists := config.GetWebhookEndpoint(name)
|
||||||
|
if !exists {
|
||||||
|
utils.SendErrorResponse(w, http.StatusNotFound, "unknown webhook endpoint: "+name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !endpoint.Enabled {
|
||||||
|
utils.SendErrorResponse(w, http.StatusForbidden, "webhook endpoint is disabled: "+name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// An endpoint without a token would accept requests from anyone, so it is
|
||||||
|
// treated as a misconfiguration rather than as an open endpoint.
|
||||||
|
if endpoint.Token == "" {
|
||||||
|
postLog.Error("Webhook endpoint " + name + " has no token configured, rejecting request")
|
||||||
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "webhook endpoint is not configured with a token: "+name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req webhookRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)).Decode(&req); err != nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object with token, subject and content")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(req.Token), []byte(endpoint.Token)) != 1 {
|
||||||
|
postLog.Warning("Webhook request rejected for endpoint " + name + ": invalid token")
|
||||||
|
utils.SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Subject == "" || req.Content == "" {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "missing required parameter: subject and content must not be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := controller.GetManager()
|
||||||
|
if manager == nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "controller manager not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := controller.Alert{
|
||||||
|
Subject: req.Subject,
|
||||||
|
Source: name,
|
||||||
|
Content: req.Content,
|
||||||
|
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered, err := manager.NotifyAlert(endpoint.NotifyPipes, alert)
|
||||||
|
if err != nil {
|
||||||
|
postLog.Error("Failed to deliver webhook alert for endpoint " + name + ": " + err.Error())
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadGateway, "failed to send alert: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postLog.Info("Webhook alert delivered for endpoint " + name)
|
||||||
|
utils.SendSuccessResponse(w, "alert sent", map[string]interface{}{
|
||||||
|
"endpoint": name,
|
||||||
|
"channels": delivered,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The incoming webhook endpoints are managed from the admin API below. They
|
||||||
|
// live in the same listener as the rest of the admin API — unlike the endpoints
|
||||||
|
// they configure, which are served on the webhook listener (see webhook.go).
|
||||||
|
//
|
||||||
|
// Every handler takes a JSON object naming the endpoint, arranged the same way
|
||||||
|
// as /api/settings/set: whatever fields the request carries are the fields that
|
||||||
|
// change, and everything else keeps its configured value. Only the fields an
|
||||||
|
// endpoint actually has are accepted, so a misspelled field is reported instead
|
||||||
|
// of being written to the configuration file.
|
||||||
|
|
||||||
|
// decodeWebhookEndpointRequest authenticates an admin request, decodes its JSON
|
||||||
|
// object body, and splits it into the endpoint name and the remaining fields.
|
||||||
|
// It answers the request itself and reports ok == false when anything is wrong.
|
||||||
|
func decodeWebhookEndpointRequest(w http.ResponseWriter, r *http.Request) (name string, fields map[string]interface{}, ok bool) {
|
||||||
|
if !utils.Auth(w, r, "POST", "admin") {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.UseNumber() // Keep values verbatim, as /api/settings/set does.
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rawName, present := body["name"]
|
||||||
|
if !present {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "missing required parameter: name")
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
name, isString := rawName.(string)
|
||||||
|
if !isString {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid parameter: name must be a string")
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
delete(body, "name")
|
||||||
|
|
||||||
|
return name, body, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebhookEndpointError maps the errors of the endpoint helpers onto the
|
||||||
|
// matching HTTP responses.
|
||||||
|
func sendWebhookEndpointError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, config.ErrWebhookEndpointExists):
|
||||||
|
utils.SendErrorResponse(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, config.ErrWebhookEndpointNotFound):
|
||||||
|
utils.SendErrorResponse(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, config.ErrWebhookEndpointInvalid):
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update webhook endpoints: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookAddHandler handles POST /api/webhook/add.
|
||||||
|
// Body: {name, ...fields}. The endpoint must not exist yet; the fields given are
|
||||||
|
// stored and any field left out starts at its default (disabled, no token, no
|
||||||
|
// notify pipes).
|
||||||
|
func WebhookAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, fields, ok := decodeWebhookEndpointRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.AddWebhookEndpoint(name, fields); err != nil {
|
||||||
|
sendWebhookEndpointError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "webhook endpoint added", map[string]interface{}{"name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookModifyHandler handles POST /api/webhook/modify.
|
||||||
|
// Body: {name, ...fields}. Only the fields given are changed.
|
||||||
|
func WebhookModifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, fields, ok := decodeWebhookEndpointRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.ModifyWebhookEndpoint(name, fields); err != nil {
|
||||||
|
sendWebhookEndpointError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "webhook endpoint updated", map[string]interface{}{"name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookDeleteHandler handles POST /api/webhook/delete.
|
||||||
|
// Body: {name}.
|
||||||
|
func WebhookDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, _, ok := decodeWebhookEndpointRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.DeleteWebhookEndpoint(name); err != nil {
|
||||||
|
sendWebhookEndpointError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "webhook endpoint deleted", map[string]interface{}{"name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookListHandler handles GET /api/webhook/list.
|
||||||
|
// Returns every configured incoming webhook endpoint, keyed by name.
|
||||||
|
func WebhookListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !utils.Auth(w, r, "GET", "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||||
|
"endpoints": config.WebhookEndpoints(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
@@ -12,7 +17,7 @@ import (
|
|||||||
|
|
||||||
// Command represents a parsed bot command.
|
// Command represents a parsed bot command.
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
Source string // Name of the pipe the command arrived on (see Controller.Name)
|
||||||
RawText string // The raw text of the command message
|
RawText string // The raw text of the command message
|
||||||
Command string // The command word (e.g., "list", "status")
|
Command string // The command word (e.g., "list", "status")
|
||||||
Args []string // Command arguments
|
Args []string // Command arguments
|
||||||
@@ -23,7 +28,7 @@ type Command struct {
|
|||||||
|
|
||||||
// Message represents a message to be sent by a controller.
|
// Message represents a message to be sent by a controller.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
Source string // The source pipe (e.g., "telegram", "qq", "onebot")
|
||||||
Content string // The message content
|
Content string // The message content
|
||||||
ChatID int64 // Chat/group ID where the message should be sent
|
ChatID int64 // Chat/group ID where the message should be sent
|
||||||
Type string // Message type (see MessageType*), used for per-member opt-outs
|
Type string // Message type (see MessageType*), used for per-member opt-outs
|
||||||
@@ -38,8 +43,33 @@ const (
|
|||||||
// MessageTypeReply marks a direct reply to a user command. Reserved for the
|
// MessageTypeReply marks a direct reply to a user command. Reserved for the
|
||||||
// BotUserOptions.EventReply opt-out.
|
// BotUserOptions.EventReply opt-out.
|
||||||
MessageTypeReply = "event_reply"
|
MessageTypeReply = "event_reply"
|
||||||
|
// MessageTypeAlert marks an alert submitted by an external application
|
||||||
|
// through the incoming webhook API. Not member-controllable: an alert is
|
||||||
|
// always delivered to the channel's recipients.
|
||||||
|
MessageTypeAlert = "alert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Alert is a free-form notification submitted by an external application
|
||||||
|
// through the incoming webhook API. Its target channels are chosen per webhook
|
||||||
|
// endpoint in config.json, not per alert.
|
||||||
|
type Alert struct {
|
||||||
|
Subject string // Short one-line title of the alert
|
||||||
|
Source string // Name of the webhook endpoint the alert was submitted to
|
||||||
|
Content string // Free-form alert body
|
||||||
|
Time string // Submission time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders the alert body for a channel, wrapping the source and content
|
||||||
|
// in Markdown when that channel has markdown enabled (see template.RenderAlert).
|
||||||
|
func (a Alert) Render(markdown bool) string {
|
||||||
|
return template.RenderAlert(template.AlertParams{
|
||||||
|
Subject: a.Subject,
|
||||||
|
Source: a.Source,
|
||||||
|
Content: a.Content,
|
||||||
|
Time: a.Time,
|
||||||
|
}, markdown)
|
||||||
|
}
|
||||||
|
|
||||||
// MemberReceives reports whether a member whose bot_user_config.json options are
|
// MemberReceives reports whether a member whose bot_user_config.json options are
|
||||||
// opts receives an automatic message of the given type. Only member-controllable
|
// opts receives an automatic message of the given type. Only member-controllable
|
||||||
// types are gated; anything else is always delivered.
|
// types are gated; anything else is always delivered.
|
||||||
@@ -58,24 +88,58 @@ type Controller interface {
|
|||||||
Start() error
|
Start() error
|
||||||
Stop()
|
Stop()
|
||||||
IsEnabled() bool
|
IsEnabled() bool
|
||||||
|
// IsMarkdown reports whether the channel renders Markdown, per its own
|
||||||
|
// "markdown" setting in config.json.
|
||||||
|
IsMarkdown() bool
|
||||||
SendStatusChange(change node.StatusChange) error
|
SendStatusChange(change node.StatusChange) error
|
||||||
SendServerList(onlineServers, offlineServers string) error
|
SendServerList(onlineServers, offlineServers string) error
|
||||||
SendExecuteResult(serverName, serverUUID, command, result string) error
|
SendExecuteResult(serverName, serverUUID, command, result string) error
|
||||||
|
// SendAlert delivers a free-form alert submitted through the incoming
|
||||||
|
// webhook API to the channel's own recipients.
|
||||||
|
SendAlert(alert Alert) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// BotController is implemented by controllers that act as chat bots and can
|
// BotController is implemented by controllers that act as chat bots and can
|
||||||
// deliver arbitrary messages, such as the bot initialization message on startup.
|
// deliver arbitrary messages, such as the bot initialization message on startup.
|
||||||
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only
|
// Only bot-type pipes (QQ_OneBot, Telegram) implement it; notification-only
|
||||||
// pipes (email, ntfy, webhook) do not.
|
// pipes (email, ntfy, webhook) do not.
|
||||||
type BotController interface {
|
type BotController interface {
|
||||||
Controller
|
Controller
|
||||||
SendMessage(message Message) error
|
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.
|
// Manager manages all controller instances and routes events.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
controllers map[string]Controller
|
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
|
var globalManager *Manager
|
||||||
@@ -84,6 +148,7 @@ var globalManager *Manager
|
|||||||
func InitManager() {
|
func InitManager() {
|
||||||
globalManager = &Manager{
|
globalManager = &Manager{
|
||||||
controllers: make(map[string]Controller),
|
controllers: make(map[string]Controller),
|
||||||
|
flapPending: make(map[string]*flapPending),
|
||||||
}
|
}
|
||||||
postLog.Info("Controller manager initialized")
|
postLog.Info("Controller manager initialized")
|
||||||
}
|
}
|
||||||
@@ -93,26 +158,84 @@ func GetManager() *Manager {
|
|||||||
return globalManager
|
return globalManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a controller to the manager.
|
// NeedsRebuild reports whether next differs from the controllerMethod section
|
||||||
func (m *Manager) Register(c Controller) {
|
// 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) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
previous := m.controllers
|
||||||
m.controllers[c.Name()] = c
|
m.controllers = make(map[string]Controller, len(next))
|
||||||
postLog.Info("Controller registered: " + c.Name())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowBotInitMessage sends the bot initialization message to all enabled
|
// ShowBotInitMessage sends the bot initialization message to all enabled
|
||||||
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
// bot controllers (QQ_OneBot and Telegram). Notification-only pipes that do
|
||||||
// not implement BotController are skipped. The message is typed
|
// not implement BotController are skipped. The message is typed
|
||||||
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
||||||
// EventBotStarted opt-out.
|
// EventBotStarted opt-out. It is rendered once per controller because the
|
||||||
|
// Markdown formatting depends on each channel's own markdown setting.
|
||||||
func (m *Manager) ShowBotInitMessage() {
|
func (m *Manager) ShowBotInitMessage() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildBotInitializationMsgParams()
|
params := template.BuildBotInitializationMsgParams()
|
||||||
content := template.Render(cfg.ControllerMessage.BotStarted, params)
|
|
||||||
|
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
if !ctrl.IsEnabled() {
|
if !ctrl.IsEnabled() {
|
||||||
@@ -124,7 +247,7 @@ func (m *Manager) ShowBotInitMessage() {
|
|||||||
}
|
}
|
||||||
message := Message{
|
message := Message{
|
||||||
Source: bot.Name(),
|
Source: bot.Name(),
|
||||||
Content: content,
|
Content: template.Render(cfg.ControllerMessage.BotStarted, params, ctrl.IsMarkdown()),
|
||||||
Type: MessageTypeBotStarted,
|
Type: MessageTypeBotStarted,
|
||||||
}
|
}
|
||||||
if err := bot.SendMessage(message); err != nil {
|
if err := bot.SendMessage(message); err != nil {
|
||||||
@@ -137,14 +260,14 @@ func (m *Manager) ShowBotInitMessage() {
|
|||||||
// controllers. The message content is identical to the /list command (same
|
// controllers. The message content is identical to the /list command (same
|
||||||
// template and parameters). Like the init message it is typed
|
// template and parameters). Like the init message it is typed
|
||||||
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
|
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
|
||||||
// receive it.
|
// receive it, and rendered once per controller so each channel's markdown
|
||||||
|
// setting is honored.
|
||||||
func (m *Manager) ShowBotServerList() {
|
func (m *Manager) ShowBotServerList() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
content := template.Render(cfg.ControllerMessage.ServerList, params)
|
|
||||||
|
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
if !ctrl.IsEnabled() {
|
if !ctrl.IsEnabled() {
|
||||||
@@ -156,7 +279,7 @@ func (m *Manager) ShowBotServerList() {
|
|||||||
}
|
}
|
||||||
message := Message{
|
message := Message{
|
||||||
Source: bot.Name(),
|
Source: bot.Name(),
|
||||||
Content: content,
|
Content: template.Render(cfg.ControllerMessage.ServerList, params, ctrl.IsMarkdown()),
|
||||||
Type: MessageTypeBotStarted,
|
Type: MessageTypeBotStarted,
|
||||||
}
|
}
|
||||||
if err := bot.SendMessage(message); err != nil {
|
if err := bot.SendMessage(message); err != nil {
|
||||||
@@ -169,12 +292,37 @@ func (m *Manager) ShowBotServerList() {
|
|||||||
// controllers. It honors the per-node allow-list in bot_node_config.json: a
|
// 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
|
// node whose enableStatusNotify is not true is skipped entirely, so no
|
||||||
// controller (chat bots or notification pipes) broadcasts its change.
|
// 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) {
|
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
||||||
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
||||||
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
||||||
return
|
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()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -188,8 +336,182 @@ func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopAll stops all registered controllers.
|
// 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
|
||||||
|
// to the remaining pipes; if no pipe accepted the alert, the error describes
|
||||||
|
// every failure.
|
||||||
|
func (m *Manager) NotifyAlert(pipes []string, alert Alert) ([]string, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
var delivered, failures []string
|
||||||
|
for _, name := range pipes {
|
||||||
|
ctrl, ok := m.controllers[name]
|
||||||
|
if !ok {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: no such channel", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ctrl.IsEnabled() {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: channel is disabled", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ctrl.SendAlert(alert); err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delivered = append(delivered, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failures) > 0 {
|
||||||
|
postLog.Warning(fmt.Sprintf("Alert %q from %s not delivered by: %s", alert.Subject, alert.Source, strings.Join(failures, "; ")))
|
||||||
|
}
|
||||||
|
if len(delivered) == 0 {
|
||||||
|
if len(failures) == 0 {
|
||||||
|
return nil, errors.New("no notify channel configured")
|
||||||
|
}
|
||||||
|
return nil, errors.New(strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
return delivered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMarkdown reports whether the pipe with the given name renders Markdown, per
|
||||||
|
// its channel's "markdown" setting in config.json. An unknown pipe renders
|
||||||
|
// plain text.
|
||||||
|
func (m *Manager) IsMarkdown(pipeName string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
ctrl, ok := m.controllers[pipeName]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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.
|
||||||
func (m *Manager) StopAll() {
|
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()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
for _, ctrl := range m.controllers {
|
for _, ctrl := range m.controllers {
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package pipes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
gomail "gopkg.in/mail.v2"
|
gomail "gopkg.in/mail.v2"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/controller"
|
||||||
"nukumizu-backend/internal/netproxy"
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
@@ -13,22 +15,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// EmailController handles email notifications via SMTP.
|
// 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 {
|
type EmailController struct {
|
||||||
cfg config.EmailConfig
|
cfg config.EmailConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailController creates a new Email controller.
|
// NewEmailController creates a new Email controller.
|
||||||
func NewEmailController(cfg config.EmailConfig) *EmailController {
|
func NewEmailController(cfg config.EmailConfig) *EmailController {
|
||||||
if cfg.NetworkUseProxy {
|
applyEmailProxy(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 &EmailController{cfg: cfg}
|
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 {
|
||||||
|
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gomail.NetDialTimeout = net.DialTimeout
|
||||||
|
}
|
||||||
|
|
||||||
// Name returns the controller name.
|
// Name returns the controller name.
|
||||||
func (e *EmailController) Name() string {
|
func (e *EmailController) Name() string {
|
||||||
return "email"
|
return "email"
|
||||||
@@ -54,6 +68,12 @@ func (e *EmailController) IsEnabled() bool {
|
|||||||
return e.cfg.Enabled
|
return e.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
|
// setting in config.json.
|
||||||
|
func (e *EmailController) IsMarkdown() bool {
|
||||||
|
return e.cfg.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
// SendStatusChange sends a status change notification via Email.
|
// SendStatusChange sends a status change notification via Email.
|
||||||
func (e *EmailController) SendStatusChange(change node.StatusChange) error {
|
func (e *EmailController) SendStatusChange(change node.StatusChange) error {
|
||||||
if !e.cfg.Enabled {
|
if !e.cfg.Enabled {
|
||||||
@@ -64,9 +84,9 @@ func (e *EmailController) SendStatusChange(change node.StatusChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, e.cfg.Markdown)
|
||||||
|
|
||||||
subject := fmt.Sprintf("Server Status Change: %s - %s", change.Name, change.Event)
|
subject := fmt.Sprintf("Server Status Change: %s - %s", change.Name, change.Event)
|
||||||
return e.sendEmail(subject, body)
|
return e.sendEmail(subject, body)
|
||||||
@@ -78,9 +98,9 @@ func (e *EmailController) SendServerList(onlineServers, offlineServers string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
body := template.Render(cfg.ControllerMessage.ServerList, params)
|
body := template.Render(cfg.ControllerMessage.ServerList, params, e.cfg.Markdown)
|
||||||
|
|
||||||
return e.sendEmail("Server List", body)
|
return e.sendEmail("Server List", body)
|
||||||
}
|
}
|
||||||
@@ -91,14 +111,28 @@ func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, res
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||||
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, e.cfg.Markdown)
|
||||||
|
|
||||||
subject := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
subject := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
||||||
return e.sendEmail(subject, body)
|
return e.sendEmail(subject, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||||
|
// configured recipients.
|
||||||
|
func (e *EmailController) SendAlert(alert controller.Alert) error {
|
||||||
|
if !e.cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(e.cfg.To) == 0 {
|
||||||
|
postLog.Debug("Email controller has no recipients configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.sendEmail(alert.Subject, alert.Render(e.cfg.Markdown))
|
||||||
|
}
|
||||||
|
|
||||||
func (e *EmailController) sendEmail(subject, body string) error {
|
func (e *EmailController) sendEmail(subject, body string) error {
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
m.SetHeader("From", e.cfg.From)
|
m.SetHeader("From", e.cfg.From)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/controller"
|
||||||
"nukumizu-backend/internal/netproxy"
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
@@ -14,6 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
|
// 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 {
|
type NtfyController struct {
|
||||||
cfg config.NtfyConfig
|
cfg config.NtfyConfig
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -52,15 +58,21 @@ func (n *NtfyController) IsEnabled() bool {
|
|||||||
return n.cfg.Enabled
|
return n.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
|
// setting in config.json.
|
||||||
|
func (n *NtfyController) IsMarkdown() bool {
|
||||||
|
return n.cfg.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
// SendStatusChange sends a status change notification via Ntfy.
|
// SendStatusChange sends a status change notification via Ntfy.
|
||||||
func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
|
func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
|
||||||
if !n.cfg.Enabled {
|
if !n.cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, n.cfg.Markdown)
|
||||||
|
|
||||||
title := fmt.Sprintf("Server %s: %s", change.Name, change.Event)
|
title := fmt.Sprintf("Server %s: %s", change.Name, change.Event)
|
||||||
return n.publish(title, message)
|
return n.publish(title, message)
|
||||||
@@ -72,9 +84,9 @@ func (n *NtfyController) SendServerList(onlineServers, offlineServers string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
message := template.Render(cfg.ControllerMessage.ServerList, params, n.cfg.Markdown)
|
||||||
|
|
||||||
return n.publish("Server List", message)
|
return n.publish("Server List", message)
|
||||||
}
|
}
|
||||||
@@ -85,14 +97,24 @@ func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, resu
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, n.cfg.Markdown)
|
||||||
|
|
||||||
title := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
title := fmt.Sprintf("Command Result: %s on %s", command, serverName)
|
||||||
return n.publish(title, message)
|
return n.publish(title, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||||
|
// configured topic.
|
||||||
|
func (n *NtfyController) SendAlert(alert controller.Alert) error {
|
||||||
|
if !n.cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return n.publish(alert.Subject, alert.Render(n.cfg.Markdown))
|
||||||
|
}
|
||||||
|
|
||||||
func (n *NtfyController) publish(title, message string) error {
|
func (n *NtfyController) publish(title, message string) error {
|
||||||
serverURL := n.cfg.Server
|
serverURL := n.cfg.Server
|
||||||
if serverURL == "" {
|
if serverURL == "" {
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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, ¶ms); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
-61
@@ -1,4 +1,4 @@
|
|||||||
package qq_napcat
|
package qq_onebot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket.
|
// oneBotEvent mirrors a OneBot 11 event pushed over the OneBot WebSocket.
|
||||||
// Only "message" events are handled; notice/request/meta_event are ignored.
|
// Only "message" events are handled; notice/request/meta_event are ignored.
|
||||||
type oneBotEvent struct {
|
type oneBotEvent struct {
|
||||||
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
|
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
|
||||||
@@ -29,45 +29,46 @@ type oneBotEvent struct {
|
|||||||
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
|
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQController handles QQ Bot interactions by connecting directly to NapCat.
|
// QQController handles QQ Bot interactions by connecting directly to OneBot.
|
||||||
type QQController struct {
|
type QQController struct {
|
||||||
cfg config.QQConfig
|
cfg config.OneBotConfig
|
||||||
napcatClient *Client
|
onebotClient *Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQQController creates a new QQ (Napcat) controller.
|
// NewQQController creates a new QQ (OneBot) controller.
|
||||||
func NewQQController(cfg config.QQConfig) *QQController {
|
func NewQQController(cfg config.OneBotConfig) *QQController {
|
||||||
q := &QQController{cfg: cfg}
|
q := &QQController{cfg: cfg}
|
||||||
if cfg.Enabled {
|
if cfg.Enabled {
|
||||||
q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy)
|
q.onebotClient = NewClient(cfg.OneBotAddr, cfg.OneBotPort, cfg.OneBotToken, cfg.NetworkUseProxy)
|
||||||
}
|
}
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the controller name.
|
// Name returns the controller name.
|
||||||
func (q *QQController) Name() string {
|
func (q *QQController) Name() string {
|
||||||
return "qq(napcat)"
|
return "qq_onebot"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the QQ controller and starts the NapCat WebSocket listener.
|
// Start initializes the QQ controller and starts the OneBot WebSocket listeners
|
||||||
|
// (the event socket and the API socket the client manages internally).
|
||||||
func (q *QQController) Start() error {
|
func (q *QQController) Start() error {
|
||||||
if !q.cfg.Enabled {
|
if !q.cfg.Enabled {
|
||||||
// postLog.Info("QQ (Napcat) controller is disabled")
|
// postLog.Info("QQ (Napcat) controller is disabled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if q.napcatClient == nil {
|
if q.onebotClient == nil {
|
||||||
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil")
|
postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is nil")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...")
|
postLog.Info("[qq_onebot] QQ (OneBot) controller started, connecting to OneBot WebSocket...")
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r))
|
postLog.Error("[qq_onebot] OneBot WS listener panic recovered: " + fmt.Sprintf("%v", r))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
q.napcatClient.Listen(q.handleNapcatEvent)
|
q.onebotClient.Listen(q.handleOneBotEvent)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -75,10 +76,10 @@ func (q *QQController) Start() error {
|
|||||||
|
|
||||||
// Stop shuts down the QQ controller and its WebSocket listener.
|
// Stop shuts down the QQ controller and its WebSocket listener.
|
||||||
func (q *QQController) Stop() {
|
func (q *QQController) Stop() {
|
||||||
if q.napcatClient != nil {
|
if q.onebotClient != nil {
|
||||||
q.napcatClient.Stop()
|
q.onebotClient.Stop()
|
||||||
}
|
}
|
||||||
postLog.Info("QQ (Napcat) controller stopped")
|
postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled returns whether the controller is enabled.
|
// IsEnabled returns whether the controller is enabled.
|
||||||
@@ -86,33 +87,49 @@ func (q *QQController) IsEnabled() bool {
|
|||||||
return q.cfg.Enabled
|
return q.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
func (q *QQController) handleNapcatEvent(raw []byte) {
|
// setting in config.json.
|
||||||
|
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) {
|
||||||
var ev oneBotEvent
|
var ev oneBotEvent
|
||||||
if err := json.Unmarshal(raw, &ev); err != nil {
|
if err := json.Unmarshal(raw, &ev); err != nil {
|
||||||
postLog.Debug("Failed to parse NapCat WS event: " + err.Error())
|
postLog.Debug("[qq_onebot] Failed to parse OneBot WS event: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatMsg {
|
// The configuration is read once per event: the debug guards below are
|
||||||
postLog.Debug("Napcat WS event received: " + string(raw))
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle message events; ignore notice/request/meta_event.
|
// Only handle message events; ignore notice/request/meta_event.
|
||||||
if ev.PostType != "message" {
|
if ev.PostType != "message" {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
if showAction {
|
||||||
postLog.Debug("Ignoring Napcat WS event: " + string(raw))
|
postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore messages the bot itself sent (echo prevention).
|
// Ignore messages the bot itself sent (echo prevention).
|
||||||
if q.isSelfMessage(ev) && !config.C_globalConfig.System.DebugMode {
|
if q.isSelfMessage(ev) && !debugMode {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if q.isSelfMessage(ev) && config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.NapcatIgnoreSelfMsg {
|
if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
if showAction {
|
||||||
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,20 +149,20 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
|||||||
|
|
||||||
response := q.processCommand(cmd)
|
response := q.processCommand(cmd)
|
||||||
if response == "" {
|
if response == "" {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
if showAction {
|
||||||
postLog.Debug("Napcat WS command discarded: " + string(raw))
|
postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply in the same chat.
|
// Reply in the same chat.
|
||||||
if ev.MessageType == "private" {
|
if ev.MessageType == "private" {
|
||||||
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
||||||
postLog.Warning("Failed to send NapCat private reply: " + err.Error())
|
postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
||||||
postLog.Warning("Failed to send NapCat group reply: " + err.Error())
|
postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +171,7 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
|||||||
// complete command to the unified processor. It returns the response text to
|
// complete command to the unified processor. It returns the response text to
|
||||||
// reply with; an empty response means the message was discarded.
|
// reply with; an empty response means the message was discarded.
|
||||||
func (q *QQController) processCommand(cmd controller.Command) string {
|
func (q *QQController) processCommand(cmd controller.Command) string {
|
||||||
|
cfg := config.Current()
|
||||||
text := cmd.RawText
|
text := cmd.RawText
|
||||||
|
|
||||||
// In "at" listen mode, require an @mention of the bot and strip it before
|
// In "at" listen mode, require an @mention of the bot and strip it before
|
||||||
@@ -162,8 +180,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
|||||||
if q.cfg.ListenMethod == "at" {
|
if q.cfg.ListenMethod == "at" {
|
||||||
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
|
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
|
||||||
if !strings.Contains(text, atMention) {
|
if !strings.Contains(text, atMention) {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
|
||||||
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
|
||||||
}
|
}
|
||||||
return "" // Not mentioned, ignore.
|
return "" // Not mentioned, ignore.
|
||||||
}
|
}
|
||||||
@@ -179,18 +197,19 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
|||||||
parsed.ChatID = cmd.ChatID
|
parsed.ChatID = cmd.ChatID
|
||||||
parsed.ChatType = cmd.ChatType
|
parsed.ChatType = cmd.ChatType
|
||||||
parsed.SenderID = cmd.SenderID
|
parsed.SenderID = cmd.SenderID
|
||||||
parsed.Source = "qq_napcat"
|
parsed.Source = q.Name()
|
||||||
|
|
||||||
// Hand the complete command to the unified processor, which checks group
|
// Hand the complete command to the unified processor, which checks group
|
||||||
// vs private, trusted groups, admin permissions, and executes it.
|
// vs private, trusted groups, admin permissions, and executes it.
|
||||||
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
|
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
|
if cfg != nil && cfg.System.DebugMode && cfg.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))
|
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 err != nil {
|
if err != nil {
|
||||||
postLog.Error("Napcat command processing failed: " + err.Error())
|
postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +226,7 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool {
|
|||||||
|
|
||||||
// adminIDs returns the QQ admin IDs from bot_user_config.json.
|
// adminIDs returns the QQ admin IDs from bot_user_config.json.
|
||||||
func (q *QQController) adminIDs() []string {
|
func (q *QQController) adminIDs() []string {
|
||||||
if c := config.C_botUserConfig; c != nil {
|
if c := config.BotUsers(); c != nil {
|
||||||
return c.QQ.Admins.IDs()
|
return c.QQ.Admins.IDs()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -215,7 +234,7 @@ func (q *QQController) adminIDs() []string {
|
|||||||
|
|
||||||
// trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json.
|
// trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json.
|
||||||
func (q *QQController) trustedGroupIDs() []string {
|
func (q *QQController) trustedGroupIDs() []string {
|
||||||
if c := config.C_botUserConfig; c != nil {
|
if c := config.BotUsers(); c != nil {
|
||||||
return c.QQ.TrustedGroups.IDs()
|
return c.QQ.TrustedGroups.IDs()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -231,7 +250,7 @@ func (q *QQController) SendMessage(message controller.Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only notify trusted groups and admins whose options allow this message type.
|
// Only notify trusted groups and admins whose options allow this message type.
|
||||||
if uc := config.C_botUserConfig; uc != nil {
|
if uc := config.BotUsers(); uc != nil {
|
||||||
for groupID, opts := range uc.QQ.TrustedGroups {
|
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||||
if !controller.MemberReceives(opts, message.Type) {
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
continue
|
continue
|
||||||
@@ -254,12 +273,12 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, q.cfg.Markdown)
|
||||||
|
|
||||||
// Only notify trusted groups and admins whose event_status_notify is true.
|
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||||
if uc := config.C_botUserConfig; uc != nil {
|
if uc := config.BotUsers(); uc != nil {
|
||||||
for groupID, opts := range uc.QQ.TrustedGroups {
|
for groupID, opts := range uc.QQ.TrustedGroups {
|
||||||
if !opts.EventStatusNotify {
|
if !opts.EventStatusNotify {
|
||||||
continue
|
continue
|
||||||
@@ -283,9 +302,9 @@ func (q *QQController) SendServerList(onlineServers, offlineServers string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown)
|
||||||
|
|
||||||
for _, groupID := range q.trustedGroupIDs() {
|
for _, groupID := range q.trustedGroupIDs() {
|
||||||
q.sendGroupMessage(groupID, message)
|
q.sendGroupMessage(groupID, message)
|
||||||
@@ -299,9 +318,9 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown)
|
||||||
|
|
||||||
for _, groupID := range q.trustedGroupIDs() {
|
for _, groupID := range q.trustedGroupIDs() {
|
||||||
q.sendGroupMessage(groupID, message)
|
q.sendGroupMessage(groupID, message)
|
||||||
@@ -309,36 +328,50 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert submitted through the incoming webhook API to all QQ
|
||||||
|
// trusted groups and admins.
|
||||||
|
func (q *QQController) SendAlert(alert controller.Alert) error {
|
||||||
|
if !q.cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.SendMessage(controller.Message{
|
||||||
|
Source: q.Name(),
|
||||||
|
Content: alert.Render(q.cfg.Markdown),
|
||||||
|
Type: controller.MessageTypeAlert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (q *QQController) sendGroupMessage(groupID string, message string) {
|
func (q *QQController) sendGroupMessage(groupID string, message string) {
|
||||||
if q.napcatClient == nil {
|
if q.onebotClient == nil {
|
||||||
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupIDInt int64
|
var groupIDInt int64
|
||||||
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
|
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
|
||||||
postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID))
|
postLog.Warning("[qq_onebot] Invalid QQ group ID: " + groupID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err))
|
postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QQController) sendPrivateMessage(userID string, message string) {
|
func (q *QQController) sendPrivateMessage(userID string, message string) {
|
||||||
if q.napcatClient == nil {
|
if q.onebotClient == nil {
|
||||||
postLog.Warning("Cannot send QQ private message: NapCat client not initialized")
|
postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIDInt int64
|
var userIDInt int64
|
||||||
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
|
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
|
||||||
postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID))
|
postLog.Warning("[qq_onebot] Invalid QQ user ID: " + userID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
||||||
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err))
|
postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
const maxMessageLen = 4000
|
const maxMessageLen = 4000
|
||||||
|
|
||||||
// sendMessage sends a text message to a chat, splitting it into chunks that fit
|
// sendMessage sends a text message to a chat, splitting it into chunks that fit
|
||||||
// Telegram's 4096-character limit. All messages are sent with
|
// Telegram's 4096-character limit. When the channel has markdown enabled the
|
||||||
// parse_mode=Markdown so fenced code blocks and inline formatting render as
|
// message is sent with parse_mode=Markdown so fenced code blocks and inline
|
||||||
// rich text. Templates must stay valid under Telegram's legacy Markdown:
|
// formatting render as rich text; templates must then stay valid under
|
||||||
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
|
// Telegram's legacy Markdown, because unpaired '*' or '_' characters (e.g. a
|
||||||
// API reject the whole message.
|
// lone '*Event: ...' label) make the API reject the whole message. With
|
||||||
|
// markdown disabled the message is sent without a parse mode, so it is
|
||||||
|
// delivered verbatim whatever it contains.
|
||||||
func (t *TelegramController) sendMessage(message controller.Message) error {
|
func (t *TelegramController) sendMessage(message controller.Message) error {
|
||||||
if t.client == nil {
|
if t.client == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -40,11 +42,15 @@ func (t *TelegramController) sendMessageChunk(chatID int64, text string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err := t.client.SendMessage(ctx, &bot.SendMessageParams{
|
params := &bot.SendMessageParams{
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Text: text,
|
Text: text,
|
||||||
ParseMode: models.ParseModeMarkdownV1, // Telegram legacy Markdown
|
}
|
||||||
})
|
if t.cfg.Markdown {
|
||||||
|
params.ParseMode = models.ParseModeMarkdownV1 // Telegram legacy Markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := t.client.SendMessage(ctx, params)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ func (t *TelegramController) IsEnabled() bool {
|
|||||||
return t.cfg.Enabled
|
return t.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
|
// setting in config.json.
|
||||||
|
func (t *TelegramController) IsMarkdown() bool {
|
||||||
|
return t.cfg.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
// handleUpdate processes a single Telegram update received via long polling. It
|
// handleUpdate processes a single Telegram update received via long polling. It
|
||||||
// is installed as the framework's default handler (every update with a Message
|
// is installed as the framework's default handler (every update with a Message
|
||||||
// reaches it). Updates are processed sequentially because the bot is created
|
// reaches it). Updates are processed sequentially because the bot is created
|
||||||
@@ -142,7 +148,7 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
|
|||||||
}
|
}
|
||||||
msg := update.Message
|
msg := update.Message
|
||||||
|
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTelegramMsg {
|
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTelegramMsg {
|
||||||
raw, _ := json.Marshal(update)
|
raw, _ := json.Marshal(update)
|
||||||
postLog.Debug("Telegram update received: " + string(raw))
|
postLog.Debug("Telegram update received: " + string(raw))
|
||||||
}
|
}
|
||||||
@@ -226,7 +232,7 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
|
|||||||
// Hand the complete command to the unified processor, which checks group
|
// Hand the complete command to the unified processor, which checks group
|
||||||
// vs private, trusted groups, admin permissions, and executes it.
|
// vs private, trusted groups, admin permissions, and executes it.
|
||||||
response, err := controller.GetManager().Trigger(parsed, t.trustedGroupIDs(), t.resolvedAdminList(), t.cfg.ListenMethod)
|
response, err := controller.GetManager().Trigger(parsed, t.trustedGroupIDs(), t.resolvedAdminList(), t.cfg.ListenMethod)
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho {
|
if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.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))
|
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 {
|
if err != nil {
|
||||||
@@ -246,7 +252,7 @@ func (t *TelegramController) SendMessage(message controller.Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only notify trusted groups and admins whose options allow this message type.
|
// Only notify trusted groups and admins whose options allow this message type.
|
||||||
if uc := config.C_botUserConfig; uc != nil {
|
if uc := config.BotUsers(); uc != nil {
|
||||||
for groupID, opts := range uc.Telegram.TrustedGroups {
|
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||||
if !controller.MemberReceives(opts, message.Type) {
|
if !controller.MemberReceives(opts, message.Type) {
|
||||||
continue
|
continue
|
||||||
@@ -269,12 +275,12 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, t.cfg.Markdown)
|
||||||
|
|
||||||
// Only notify trusted groups and admins whose event_status_notify is true.
|
// Only notify trusted groups and admins whose event_status_notify is true.
|
||||||
if uc := config.C_botUserConfig; uc != nil {
|
if uc := config.BotUsers(); uc != nil {
|
||||||
for groupID, opts := range uc.Telegram.TrustedGroups {
|
for groupID, opts := range uc.Telegram.TrustedGroups {
|
||||||
if !opts.EventStatusNotify {
|
if !opts.EventStatusNotify {
|
||||||
continue
|
continue
|
||||||
@@ -297,9 +303,9 @@ func (t *TelegramController) SendServerList(onlineServers, offlineServers string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
message := template.Render(cfg.ControllerMessage.ServerList, params, t.cfg.Markdown)
|
||||||
|
|
||||||
t.sendToGroups(message)
|
t.sendToGroups(message)
|
||||||
return nil
|
return nil
|
||||||
@@ -311,14 +317,28 @@ func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, t.cfg.Markdown)
|
||||||
|
|
||||||
t.sendToGroups(message)
|
t.sendToGroups(message)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert submitted through the incoming webhook API to all
|
||||||
|
// Telegram trusted groups and admins.
|
||||||
|
func (t *TelegramController) SendAlert(alert controller.Alert) error {
|
||||||
|
if !t.cfg.Enabled || t.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.SendMessage(controller.Message{
|
||||||
|
Source: t.Name(),
|
||||||
|
Content: alert.Render(t.cfg.Markdown),
|
||||||
|
Type: controller.MessageTypeAlert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// telegramChatType maps a Telegram chat type to the unified ChatType value used
|
// telegramChatType maps a Telegram chat type to the unified ChatType value used
|
||||||
// by the controller package. Empty means the chat type is unsupported.
|
// by the controller package. Empty means the chat type is unsupported.
|
||||||
func telegramChatType(chatType string) string {
|
func telegramChatType(chatType string) string {
|
||||||
@@ -375,7 +395,7 @@ func (t *TelegramController) resolveUsername(username string) (int64, bool) {
|
|||||||
// adminIDs returns the Telegram admin entries (numeric user ID or @username)
|
// adminIDs returns the Telegram admin entries (numeric user ID or @username)
|
||||||
// from bot_user_config.json.
|
// from bot_user_config.json.
|
||||||
func (t *TelegramController) adminIDs() []string {
|
func (t *TelegramController) adminIDs() []string {
|
||||||
if c := config.C_botUserConfig; c != nil {
|
if c := config.BotUsers(); c != nil {
|
||||||
return c.Telegram.Admins.IDs()
|
return c.Telegram.Admins.IDs()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -383,7 +403,7 @@ func (t *TelegramController) adminIDs() []string {
|
|||||||
|
|
||||||
// trustedGroupIDs returns the Telegram trusted group IDs from bot_user_config.json.
|
// trustedGroupIDs returns the Telegram trusted group IDs from bot_user_config.json.
|
||||||
func (t *TelegramController) trustedGroupIDs() []string {
|
func (t *TelegramController) trustedGroupIDs() []string {
|
||||||
if c := config.C_botUserConfig; c != nil {
|
if c := config.BotUsers(); c != nil {
|
||||||
return c.Telegram.TrustedGroups.IDs()
|
return c.Telegram.TrustedGroups.IDs()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/controller"
|
||||||
"nukumizu-backend/internal/netproxy"
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
@@ -15,6 +16,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// WebhookController handles notifications via generic HTTP webhooks.
|
// 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 {
|
type WebhookController struct {
|
||||||
cfg config.WebhookConfig
|
cfg config.WebhookConfig
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -53,15 +59,21 @@ func (w *WebhookController) IsEnabled() bool {
|
|||||||
return w.cfg.Enabled
|
return w.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
|
// setting in config.json.
|
||||||
|
func (w *WebhookController) IsMarkdown() bool {
|
||||||
|
return w.cfg.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
// SendStatusChange sends a status change notification via Webhook.
|
// SendStatusChange sends a status change notification via Webhook.
|
||||||
func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
|
func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
|
||||||
if !w.cfg.Enabled {
|
if !w.cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromStatusChange(change)
|
params := template.BuildParamsFromStatusChange(change)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, w.cfg.Markdown)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"event": change.Event,
|
"event": change.Event,
|
||||||
@@ -80,16 +92,16 @@ func (w *WebhookController) SendServerList(onlineServers, offlineServers string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
message := template.Render(cfg.ControllerMessage.ServerList, params, w.cfg.Markdown)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"type": "serverList",
|
"type": "serverList",
|
||||||
"onlineServers": params.OnlineServers,
|
"onlineServers": params.OnlineServers,
|
||||||
"offlineServers": params.OfflineServers,
|
"offlineServers": params.OfflineServers,
|
||||||
"message": message,
|
"message": message,
|
||||||
"time": params.Time,
|
"time": params.Time,
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.send(payload)
|
return w.send(payload)
|
||||||
@@ -101,9 +113,9 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
||||||
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, w.cfg.Markdown)
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"type": "executeResult",
|
"type": "executeResult",
|
||||||
@@ -118,6 +130,25 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
|
|||||||
return w.send(payload)
|
return w.send(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an alert submitted through the incoming webhook API to the
|
||||||
|
// configured URL.
|
||||||
|
func (w *WebhookController) SendAlert(alert controller.Alert) error {
|
||||||
|
if !w.cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"type": "alert",
|
||||||
|
"subject": alert.Subject,
|
||||||
|
"source": alert.Source,
|
||||||
|
"content": alert.Content,
|
||||||
|
"message": alert.Render(w.cfg.Markdown),
|
||||||
|
"time": alert.Time,
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *WebhookController) send(payload map[string]interface{}) error {
|
func (w *WebhookController) send(payload map[string]interface{}) error {
|
||||||
method := w.cfg.Method
|
method := w.cfg.Method
|
||||||
if method == "" {
|
if method == "" {
|
||||||
|
|||||||
@@ -10,16 +10,27 @@ import (
|
|||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// commandMarkdown reports whether responses to the given command are rendered
|
||||||
|
// with Markdown, per the markdown setting of the pipe the command came from
|
||||||
|
// (see Command.Source).
|
||||||
|
func commandMarkdown(cmd Command) bool {
|
||||||
|
mgr := GetManager()
|
||||||
|
if mgr == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mgr.IsMarkdown(cmd.Source)
|
||||||
|
}
|
||||||
|
|
||||||
func handleHelp(cmd Command) (string, error) {
|
func handleHelp(cmd Command) (string, error) {
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildBotInitializationMsgParams()
|
params := template.BuildBotInitializationMsgParams()
|
||||||
return template.Render(cfg.ControllerMessage.BotHelp, params, cmd.Source), nil
|
return template.Render(cfg.ControllerMessage.BotHelp, params, commandMarkdown(cmd)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleList(cmd Command) (string, error) {
|
func handleList(cmd Command) (string, error) {
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromServerList()
|
params := template.BuildParamsFromServerList()
|
||||||
return template.Render(cfg.ControllerMessage.ServerList, params, cmd.Source), nil
|
return template.Render(cfg.ControllerMessage.ServerList, params, commandMarkdown(cmd)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStatus(cmd Command) (string, error) {
|
func handleStatus(cmd Command) (string, error) {
|
||||||
@@ -128,9 +139,9 @@ func handleRun(cmd Command) (string, error) {
|
|||||||
return fmt.Sprintf("Error getting results: %v", err), nil
|
return fmt.Sprintf("Error getting results: %v", err), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
|
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
|
||||||
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, cmd.Source), nil
|
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, commandMarkdown(cmd)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleInfo(cmd Command) (string, error) {
|
func handleInfo(cmd Command) (string, error) {
|
||||||
@@ -177,10 +188,10 @@ func handleInfo(cmd Command) (string, error) {
|
|||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func telegram_handleStart() (string, error) {
|
func telegram_handleStart(cmd Command) (string, error) {
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
params := template.BuildBotInitializationMsgParams()
|
params := template.BuildBotInitializationMsgParams()
|
||||||
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, "telegram"), nil
|
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, commandMarkdown(cmd)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetIP(cmd Command) (string, error) {
|
func handleGetIP(cmd Command) (string, error) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"nukumizu-backend/postLog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Trigger validates an incoming command against the pipe's authorization
|
// Trigger validates an incoming command against the pipe's authorization
|
||||||
@@ -16,9 +18,11 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
|||||||
// Check whether the command arrived in a group or a private chat.
|
// Check whether the command arrived in a group or a private chat.
|
||||||
if cmd.ChatType == "group" {
|
if cmd.ChatType == "group" {
|
||||||
// Group commands are only honored from trusted groups, otherwise discard.
|
// Group commands are only honored from trusted groups, otherwise discard.
|
||||||
|
// postLog.Debug(fmt.Sprintf("trustedGroups: %v", trustedGroups))
|
||||||
if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
|
if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
postLog.Debug("isTrustedGroup")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commands that require special permission must be sent by an admin.
|
// Commands that require special permission must be sent by an admin.
|
||||||
@@ -37,16 +41,17 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
|||||||
if response == "" && listenMethod == "at" {
|
if response == "" && listenMethod == "at" {
|
||||||
return "Unknown command: /" + cmd.Command, nil
|
return "Unknown command: /" + cmd.Command, nil
|
||||||
}
|
}
|
||||||
|
// postLog.Debug("[controller] Trigger.resp: " + response)
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteCommand processes a parsed bot command and returns the response text.
|
// RouteCommand processes a parsed bot command and returns the response text.
|
||||||
// The actual command execution for every pipe is unified here.
|
// The actual command execution for every pipe is unified here.
|
||||||
func (m *Manager) RouteCommand(cmd Command) (string, error) {
|
func (m *Manager) RouteCommand(cmd Command) (string, error) {
|
||||||
if cmd.Source == "telegram"{
|
if cmd.Source == "telegram" {
|
||||||
switch cmd.Command {
|
switch cmd.Command {
|
||||||
case "start":
|
case "start":
|
||||||
return telegram_handleStart()
|
return telegram_handleStart(cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch cmd.Command {
|
switch cmd.Command {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import (
|
|||||||
"nukumizu-backend/postLog"
|
"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
|
// NodeInfo represents a single node as returned by Komari's
|
||||||
// common:getNodes RPC2 method.
|
// common:getNodes RPC2 method.
|
||||||
type NodeInfo struct {
|
type NodeInfo struct {
|
||||||
@@ -146,7 +154,7 @@ func (c *Client) Login(username, password string) error {
|
|||||||
|
|
||||||
var kr KomariResponse
|
var kr KomariResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||||
if config.C_globalConfig.System.DebugMode {
|
if config.IsDebugMode() {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
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.\nResponse: %s", err, respBody)
|
||||||
}
|
}
|
||||||
@@ -240,7 +248,7 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
|
|||||||
postLog.Warning("Failed to save bot node config: " + err.Error())
|
postLog.Warning("Failed to save bot node config: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
// postLog.Debug(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +340,7 @@ func (c *Client) ExecTask(uuids []string, command string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
|
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
if taskEchoEnabled() {
|
||||||
postLog.Debug(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
|
postLog.Debug(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
|
||||||
}
|
}
|
||||||
return result.TaskID, nil
|
return result.TaskID, nil
|
||||||
@@ -375,7 +383,7 @@ func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) {
|
|||||||
// PollTaskResult polls for task results every 1 second until all results are
|
// PollTaskResult polls for task results every 1 second until all results are
|
||||||
// available or 60 seconds have elapsed.
|
// available or 60 seconds have elapsed.
|
||||||
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
if taskEchoEnabled() {
|
||||||
postLog.Debug(fmt.Sprintf("Polling for Komari task %s results...", taskID))
|
postLog.Debug(fmt.Sprintf("Polling for Komari task %s results...", taskID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +401,7 @@ func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho {
|
if taskEchoEnabled() {
|
||||||
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
|
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
|
|||||||
@@ -409,7 +409,10 @@ func GetWSClient() *WSClient {
|
|||||||
|
|
||||||
// LoginAndStart performs the Komari login and returns an error if it fails.
|
// LoginAndStart performs the Komari login and returns an error if it fails.
|
||||||
func LoginAndStart() error {
|
func LoginAndStart() error {
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("configuration not loaded")
|
||||||
|
}
|
||||||
client := GetClient()
|
client := GetClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("komari client not initialized")
|
return fmt.Errorf("komari client not initialized")
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// network proxy configured in the system config. Each caller decides whether
|
// network proxy configured in the system config. Each caller decides whether
|
||||||
// to use the proxy by passing its own useProxy flag (the per-channel
|
// to use the proxy by passing its own useProxy flag (the per-channel
|
||||||
// networkUseProxy setting), so proxying is opt-in 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
|
package netproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,7 +24,11 @@ import (
|
|||||||
// proxyURL returns the system-wide network proxy URL, or nil when none is
|
// proxyURL returns the system-wide network proxy URL, or nil when none is
|
||||||
// configured. A missing scheme is normalized to http:// for convenience.
|
// configured. A missing scheme is normalized to http:// for convenience.
|
||||||
func proxyURL() *url.URL {
|
func proxyURL() *url.URL {
|
||||||
raw := config.C_globalConfig.System.NetworkProxy
|
cfg := config.Current()
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := cfg.System.NetworkProxy
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -35,19 +43,23 @@ func proxyURL() *url.URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProxyFunc returns a transport proxy function that routes requests through
|
// ProxyFunc returns a transport proxy function that routes requests through
|
||||||
// the configured network proxy when enabled. It returns nil when the caller
|
// the configured network proxy when enabled, and nil when the caller opts out
|
||||||
// opts out or no proxy is configured, meaning direct connection. The returned
|
// of proxying entirely. The returned function is compatible with both
|
||||||
// function is compatible with both http.Transport.Proxy and
|
// http.Transport.Proxy and websocket.Dialer.Proxy.
|
||||||
// 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.
|
||||||
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
|
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
|
||||||
if !useProxy {
|
if !useProxy {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
u := proxyURL()
|
return func(*http.Request) (*url.URL, error) {
|
||||||
if u == nil {
|
return proxyURL(), nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return http.ProxyURL(u)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPClient builds an http.Client that sends traffic through the configured
|
// HTTPClient builds an http.Client that sends traffic through the configured
|
||||||
@@ -67,10 +79,16 @@ func HTTPClient(useProxy bool, timeout time.Duration) *http.Client {
|
|||||||
// through the configured HTTP CONNECT proxy when enabled. Its signature
|
// through the configured HTTP CONNECT proxy when enabled. Its signature
|
||||||
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
|
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
|
||||||
// to send SMTP over the proxy.
|
// 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) {
|
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) {
|
return func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||||
if !useProxy || u == nil {
|
if !useProxy {
|
||||||
|
return net.DialTimeout(network, addr, timeout)
|
||||||
|
}
|
||||||
|
u := proxyURL()
|
||||||
|
if u == nil {
|
||||||
return net.DialTimeout(network, addr, timeout)
|
return net.DialTimeout(network, addr, timeout)
|
||||||
}
|
}
|
||||||
return dialViaProxy(u, addr, timeout)
|
return dialViaProxy(u, addr, timeout)
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ type StatusChangeCallback func(change StatusChange)
|
|||||||
// All methods are thread-safe.
|
// All methods are thread-safe.
|
||||||
type Tracker struct {
|
type Tracker struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
count int
|
||||||
nodes map[string]*Node // uuid → Node
|
nodes map[string]*Node // uuid → Node
|
||||||
uuidToName map[string]string // uuid → name (from node list)
|
uuidToName map[string]string // uuid → name (from node list)
|
||||||
onlineSet map[string]bool // which uuids are currently online
|
onlineSet map[string]bool // which uuids are currently online
|
||||||
@@ -161,6 +162,13 @@ 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
|
// 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
|
// static Info metadata. entries maps UUID → node metadata from the Komari node
|
||||||
// list.
|
// list.
|
||||||
@@ -175,6 +183,8 @@ func (t *Tracker) UpdateNodeList(entries map[string]NodeListEntry) {
|
|||||||
t.knownUUIDs[uuid] = true
|
t.knownUUIDs[uuid] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.count = len(entries)
|
||||||
|
|
||||||
t.uuidToName = make(map[string]string, len(entries))
|
t.uuidToName = make(map[string]string, len(entries))
|
||||||
for uuid, entry := range entries {
|
for uuid, entry := range entries {
|
||||||
t.uuidToName[uuid] = entry.Name
|
t.uuidToName[uuid] = entry.Name
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type Params struct {
|
|||||||
Message string
|
Message string
|
||||||
Command string
|
Command string
|
||||||
Result string
|
Result string
|
||||||
|
Subject string // Alert subject (see AlertParams)
|
||||||
|
Source string // Alert source (see AlertParams)
|
||||||
|
Content string // Alert content (see AlertParams)
|
||||||
OnlineServers string // Pre-formatted multi-line list
|
OnlineServers string // Pre-formatted multi-line list
|
||||||
OfflineServers string // Pre-formatted multi-line list
|
OfflineServers string // Pre-formatted multi-line list
|
||||||
SoftwareVersion string
|
SoftwareVersion string
|
||||||
@@ -30,6 +33,37 @@ type Params struct {
|
|||||||
SoftwareDescription string
|
SoftwareDescription string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlertTemplate is the body format of an alert submitted by an external
|
||||||
|
// application through the incoming webhook API.
|
||||||
|
const AlertTemplate = "{{ subject }}\n- Source: {{ source }}\n- Content:\n{{ content }}\n\n- Time: {{ time }}\nSent by Nukumizu Alert System"
|
||||||
|
|
||||||
|
// AlertParams holds the parameters of an alert submitted through the incoming
|
||||||
|
// webhook API.
|
||||||
|
type AlertParams struct {
|
||||||
|
Subject string // Short one-line title of the alert
|
||||||
|
Source string // Name of the webhook endpoint the alert was submitted to
|
||||||
|
Content string // Free-form alert body
|
||||||
|
Time string // Submission time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAlert renders the body of an alert for a channel. The alert source and
|
||||||
|
// content may be wrapped in Markdown — the source in inline code, the content
|
||||||
|
// in a fenced code block — when the target channel has markdown enabled
|
||||||
|
// (markdown); everything else, the timestamp included, stays plain text.
|
||||||
|
func RenderAlert(alert AlertParams, markdown bool) string {
|
||||||
|
params := Params{
|
||||||
|
Time: alert.Time,
|
||||||
|
Subject: alert.Subject,
|
||||||
|
Source: alert.Source,
|
||||||
|
Content: alert.Content,
|
||||||
|
}
|
||||||
|
if markdown {
|
||||||
|
params.Source = "`" + params.Source + "`"
|
||||||
|
params.Content = "```\n" + params.Content + "\n```"
|
||||||
|
}
|
||||||
|
return Render(AlertTemplate, params, false)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildBotInitializationMsgParams creates template parameters for the bot initialization message.
|
// BuildBotInitializationMsgParams creates template parameters for the bot initialization message.
|
||||||
func BuildBotInitializationMsgParams() Params {
|
func BuildBotInitializationMsgParams() Params {
|
||||||
return Params{
|
return Params{
|
||||||
@@ -80,7 +114,13 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render substitutes {{ paramName }} placeholders in a template string.
|
// Render substitutes {{ paramName }} placeholders in a template string. The
|
||||||
|
// markdown argument is the target channel's markdown setting: when true the
|
||||||
|
// values that are meant to be read verbatim (UUIDs, messages, commands, command
|
||||||
|
// results) are wrapped in Markdown code spans and blocks, otherwise every value
|
||||||
|
// is inserted as plain text. Whether a channel renders Markdown comes from the
|
||||||
|
// configuration alone — the renderer never infers it from the channel name.
|
||||||
|
//
|
||||||
// Supported placeholders:
|
// Supported placeholders:
|
||||||
// - {{ time }} — current server time
|
// - {{ time }} — current server time
|
||||||
// - {{ serverName }} — server name
|
// - {{ serverName }} — server name
|
||||||
@@ -90,6 +130,9 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
|||||||
// - {{ message }} — event descriptive message
|
// - {{ message }} — event descriptive message
|
||||||
// - {{ command }} — executed command
|
// - {{ command }} — executed command
|
||||||
// - {{ result }} — command execution result
|
// - {{ result }} — command execution result
|
||||||
|
// - {{ subject }} — alert subject
|
||||||
|
// - {{ source }} — alert source
|
||||||
|
// - {{ content }} — alert content
|
||||||
// - {{ list.onlineServers }} — multi-line online server list
|
// - {{ list.onlineServers }} — multi-line online server list
|
||||||
// - {{ list.offlineServers }} — multi-line offline server list
|
// - {{ list.offlineServers }} — multi-line offline server list
|
||||||
// - {{ softwareVersion }} — software version
|
// - {{ softwareVersion }} — software version
|
||||||
@@ -99,19 +142,20 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
|
|||||||
// - {{ softwareBuildTime }} — software build time
|
// - {{ softwareBuildTime }} — software build time
|
||||||
// - {{ softwareDeveloper }} — software developer
|
// - {{ softwareDeveloper }} — software developer
|
||||||
// - {{ softwareDescription }} — software description
|
// - {{ softwareDescription }} — software description
|
||||||
func Render(tmpl string, params Params, source ...string) string {
|
func Render(tmpl string, params Params, markdown bool) string {
|
||||||
result := tmpl
|
result := tmpl
|
||||||
|
|
||||||
if len(source) > 0 && source[0] == "telegram" {
|
if markdown {
|
||||||
// Telegram requires special formatting for code blocks and inline code.
|
// Channels that render Markdown get code blocks and inline code for the
|
||||||
result = strings.ReplaceAll(result, "{{ time }}", "**" + params.Time + "**")
|
// values that are read verbatim.
|
||||||
result = strings.ReplaceAll(result, "{{ serverName }}", "**" + params.ServerName + "**")
|
result = strings.ReplaceAll(result, "{{ time }}", "**"+params.Time+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`" + params.ServerUUID + "`")
|
result = strings.ReplaceAll(result, "{{ serverName }}", "**"+params.ServerName+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ upStatus }}", "**" + params.UpStatus + "**")
|
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`"+params.ServerUUID+"`")
|
||||||
result = strings.ReplaceAll(result, "{{ event }}", "**" + params.Event + "**")
|
result = strings.ReplaceAll(result, "{{ upStatus }}", "**"+params.UpStatus+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ message }}", "`" + params.Message + "`")
|
result = strings.ReplaceAll(result, "{{ event }}", "**"+params.Event+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ command }}", "`" + params.Command + "`")
|
result = strings.ReplaceAll(result, "{{ message }}", "`"+params.Message+"`")
|
||||||
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n" + params.Result + "\n```")
|
result = strings.ReplaceAll(result, "{{ command }}", "`"+params.Command+"`")
|
||||||
|
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n"+params.Result+"\n```")
|
||||||
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
|
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
|
||||||
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
|
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
|
||||||
result = strings.ReplaceAll(result, "{{ softwareVersion }}", params.SoftwareVersion)
|
result = strings.ReplaceAll(result, "{{ softwareVersion }}", params.SoftwareVersion)
|
||||||
@@ -140,6 +184,13 @@ func Render(tmpl string, params Params, source ...string) string {
|
|||||||
result = strings.ReplaceAll(result, "{{ softwareDeveloper }}", params.SoftwareDeveloper)
|
result = strings.ReplaceAll(result, "{{ softwareDeveloper }}", params.SoftwareDeveloper)
|
||||||
result = strings.ReplaceAll(result, "{{ softwareDescription }}", params.SoftwareDescription)
|
result = strings.ReplaceAll(result, "{{ softwareDescription }}", params.SoftwareDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alert values carry their own formatting (see RenderAlert), so they are
|
||||||
|
// substituted identically in both branches.
|
||||||
|
result = strings.ReplaceAll(result, "{{ subject }}", params.Subject)
|
||||||
|
result = strings.ReplaceAll(result, "{{ source }}", params.Source)
|
||||||
|
result = strings.ReplaceAll(result, "{{ content }}", params.Content)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"nukumizu-backend/global"
|
"nukumizu-backend/global"
|
||||||
"nukumizu-backend/internal/controller"
|
"nukumizu-backend/internal/controller"
|
||||||
"nukumizu-backend/internal/controller/pipes"
|
"nukumizu-backend/internal/controller/pipes"
|
||||||
"nukumizu-backend/internal/controller/pipes/qq_napcat"
|
"nukumizu-backend/internal/controller/pipes/qq_onebot"
|
||||||
"nukumizu-backend/internal/controller/pipes/telegram"
|
"nukumizu-backend/internal/controller/pipes/telegram"
|
||||||
"nukumizu-backend/internal/komari"
|
"nukumizu-backend/internal/komari"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
@@ -58,6 +58,27 @@ func main() {
|
|||||||
postLog.SetDebugMode(cfg.System.DebugMode)
|
postLog.SetDebugMode(cfg.System.DebugMode)
|
||||||
postLog.InitLogBroadcaster()
|
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
|
dbPath := cfg.DBPath
|
||||||
|
|
||||||
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
|
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
|
||||||
@@ -110,7 +131,7 @@ func main() {
|
|||||||
initControllers()
|
initControllers()
|
||||||
|
|
||||||
// Send the bot initialization message to all enabled bot controllers
|
// Send the bot initialization message to all enabled bot controllers
|
||||||
// (QQ_NapCat, Telegram), then the server list right after it once the
|
// (QQ_OneBot, Telegram), then the server list right after it once the
|
||||||
// initial status refresh has completed so the list reflects the real
|
// initial status refresh has completed so the list reflects the real
|
||||||
// online/offline state.
|
// online/offline state.
|
||||||
if mgr := controller.GetManager(); mgr != nil {
|
if mgr := controller.GetManager(); mgr != nil {
|
||||||
@@ -146,6 +167,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// --- Start the incoming webhook listener ---
|
||||||
|
startWebhookServer(cfg)
|
||||||
|
|
||||||
// --- Graceful shutdown ---
|
// --- Graceful shutdown ---
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -167,83 +191,57 @@ func main() {
|
|||||||
postLog.Info("Server stopped")
|
postLog.Info("Server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// initControllers initializes and starts all configured controllers.
|
// startWebhookServer serves the incoming webhook API on its own listener. The
|
||||||
func initControllers() {
|
// API is not exposed on the main listener: external applications post alerts to
|
||||||
cfg := config.C_globalConfig
|
// this port only, so its rate limiter and CORS policy are configured
|
||||||
mgr := controller.GetManager()
|
// independently. A failure to bind it is logged rather than fatal — the rest of
|
||||||
if mgr == nil {
|
// the program (bots, status monitoring) keeps running without it.
|
||||||
|
func startWebhookServer(cfg *config.Config) {
|
||||||
|
if !cfg.Webhook.Enabled {
|
||||||
|
postLog.Warning("Incoming webhook API is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ (Napcat) controller.
|
handler := utils.RateLimitMiddleware(SetupWebhookRouter())
|
||||||
qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ)
|
handler = utils.CORSMiddleware(handler)
|
||||||
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.
|
addr := fmt.Sprintf("%s:%s", cfg.Webhook.ListenAddr, cfg.Webhook.ListenPort)
|
||||||
tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram)
|
postLog.Info(fmt.Sprintf("Webhook API listening on %s", addr))
|
||||||
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() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
|
postLog.Error(fmt.Sprintf("Webhook server panic: %v", r))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err := emailCtrl.Start(); err != nil {
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||||
postLog.Error("Failed to start Email controller: " + err.Error())
|
postLog.Error("Webhook server error: " + err.Error())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// Ntfy controller (status-only).
|
// buildControllers constructs one controller per channel from the given
|
||||||
ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy)
|
// configuration. The set is built fresh whenever controller settings change,
|
||||||
mgr.Register(ntfyCtrl)
|
// because a controller reads its settings once at construction and two of them
|
||||||
go func() {
|
// own connections that cannot be re-pointed.
|
||||||
defer func() {
|
func buildControllers(cfg *config.Config) []controller.Controller {
|
||||||
if r := recover(); r != nil {
|
return []controller.Controller{
|
||||||
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
|
qq_onebot.NewQQController(cfg.ControllerMethod.QQ_OneBot),
|
||||||
}
|
telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
|
||||||
}()
|
pipes.NewEmailController(cfg.ControllerMethod.Email),
|
||||||
if err := ntfyCtrl.Start(); err != nil {
|
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
|
||||||
postLog.Error("Failed to start Ntfy controller: " + err.Error())
|
pipes.NewWebhookController(cfg.ControllerMethod.Webhook),
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
|
|
||||||
// Webhook controller (status-only).
|
// initControllers installs the initial controller set.
|
||||||
webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook)
|
func initControllers() {
|
||||||
mgr.Register(webhookCtrl)
|
cfg := config.Current()
|
||||||
go func() {
|
mgr := controller.GetManager()
|
||||||
defer func() {
|
if mgr == nil || cfg == nil {
|
||||||
if r := recover(); r != nil {
|
return
|
||||||
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
|
}
|
||||||
}
|
mgr.ReplaceAll(buildControllers(cfg), cfg.ControllerMethod)
|
||||||
}()
|
|
||||||
if err := webhookCtrl.Start(); err != nil {
|
|
||||||
postLog.Error("Failed to start Webhook controller: " + err.Error())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startBackgroundTasks starts periodic background goroutines.
|
// startBackgroundTasks starts periodic background goroutines.
|
||||||
@@ -262,11 +260,15 @@ func startBackgroundTasks(_ *config.Config) {
|
|||||||
if client == nil {
|
if client == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
orgNodeCount := node.GetTracker().GetNodeCount()
|
||||||
nodes, err := client.FetchNodes()
|
nodes, err := client.FetchNodes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
postLog.Warning("Failed to refresh node list: " + err.Error())
|
postLog.Warning("Failed to refresh node list: " + err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if node.GetTracker().GetNodeCount() != orgNodeCount {
|
||||||
|
postLog.Info(fmt.Sprintf("Refreshed node list: %d nodes", len(nodes)))
|
||||||
|
}
|
||||||
tracker := node.GetTracker()
|
tracker := node.GetTracker()
|
||||||
if tracker != nil {
|
if tracker != nil {
|
||||||
tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
|
tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"nukumizu-backend/handler"
|
"nukumizu-backend/handler"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
|
"nukumizu-backend/utils"
|
||||||
"nukumizu-backend/web"
|
"nukumizu-backend/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,14 +30,24 @@ func SetupRouter() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
|
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
|
||||||
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
|
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
|
||||||
|
|
||||||
|
// Incoming webhook endpoint management (admin only). These configure the
|
||||||
|
// endpoints served by SetupWebhookRouter, which runs on its own listener.
|
||||||
|
mux.HandleFunc("/api/webhook/add", handler.WebhookAddHandler)
|
||||||
|
mux.HandleFunc("/api/webhook/modify", handler.WebhookModifyHandler)
|
||||||
|
mux.HandleFunc("/api/webhook/delete", handler.WebhookDeleteHandler)
|
||||||
|
mux.HandleFunc("/api/webhook/list", handler.WebhookListHandler)
|
||||||
|
|
||||||
// Health check endpoint.
|
// Health check endpoint.
|
||||||
mux.HandleFunc("/health", handler.HealthHandler)
|
mux.HandleFunc("/health", handler.HealthHandler)
|
||||||
|
|
||||||
// WebSocket log streaming endpoint.
|
// WebSocket log streaming endpoint (admin only). The middleware authenticates
|
||||||
|
// the upgrade request, so an anonymous or non-admin client is rejected before
|
||||||
|
// any log entry leaves the server.
|
||||||
logBroadcaster := postLog.GetLogBroadcaster()
|
logBroadcaster := postLog.GetLogBroadcaster()
|
||||||
if logBroadcaster != nil {
|
if logBroadcaster != nil {
|
||||||
logSocketHandler := postLog.NewLogSocketHandler(logBroadcaster)
|
logSocketHandler := postLog.NewLogSocketHandler(logBroadcaster)
|
||||||
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
|
adminOnly := utils.AuthWS("admin")
|
||||||
|
mux.Handle("/api/system/getLogs", adminOnly(http.HandlerFunc(logSocketHandler.Handle)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static file serving for the web frontend.
|
// Static file serving for the web frontend.
|
||||||
@@ -46,6 +57,25 @@ func SetupRouter() *http.ServeMux {
|
|||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetupWebhookRouter registers the routes of the incoming webhook API. Unlike
|
||||||
|
// 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/<name>.
|
||||||
|
func SetupWebhookRouter() *http.ServeMux {
|
||||||
|
postLog.Info("Setting up webhook routers...")
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// The wildcard segment selects the endpoint; requests for a name that is not
|
||||||
|
// configured fall through to the handler, which answers with a JSON 404.
|
||||||
|
mux.HandleFunc("/api/webhook/post/{name}", handler.WebhookHandler)
|
||||||
|
mux.HandleFunc("/", NotFoundHandler)
|
||||||
|
|
||||||
|
postLog.Info("Webhook router setup completed")
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
// NotFoundHandler returns a 404 JSON response for unknown routes.
|
// NotFoundHandler returns a 404 JSON response for unknown routes.
|
||||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
postLog.Debug(fmt.Sprintf("Unknown request: %s %s", r.Method, r.URL.Path))
|
postLog.Debug(fmt.Sprintf("Unknown request: %s %s", r.Method, r.URL.Path))
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/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" .
|
||||||
+124
-47
@@ -100,62 +100,58 @@ func GetUserLevelFromRequest(r *http.Request) string {
|
|||||||
return tokenInfo.Level
|
return tokenInfo.Level
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth is the central authentication and authorization function.
|
// checkTimestamp validates a Unix timestamp in seconds against the server clock
|
||||||
// It validates the request method, X-Timestamp header (30min tolerance),
|
// (30 minute tolerance per agent.md). The check is skipped entirely in debug
|
||||||
// X-Token header, and permission level. Returns true if the request is authorized.
|
// mode. It returns 0 when the timestamp is acceptable, otherwise the HTTP status
|
||||||
|
// and message to reject the request with.
|
||||||
|
func checkTimestamp(timestamp string) (int, string) {
|
||||||
|
if config.IsDebugMode() {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if timestamp == "" {
|
||||||
|
return http.StatusUnauthorized, "missing timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusUnauthorized, "invalid timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
diff := now - ts
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff > 1800 {
|
||||||
|
return http.StatusUnauthorized, "request expired"
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPermission validates a session token against the required permission
|
||||||
|
// level and refreshes the token's idle timer on success.
|
||||||
//
|
//
|
||||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||||
// When level is "admin", only "admin" tokens are accepted.
|
// When level is "admin", only "admin" tokens are accepted.
|
||||||
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
//
|
||||||
// Validate HTTP method.
|
// It returns 0 when the token is authorized, otherwise the HTTP status and
|
||||||
if r.Method != targetMethod {
|
// message to reject the request with.
|
||||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
func checkPermission(token string, targetLevel string) (int, string) {
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate X-Timestamp.
|
|
||||||
timestamp := r.Header.Get("X-Timestamp")
|
|
||||||
if !config.IsDebugMode() {
|
|
||||||
if timestamp == "" {
|
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "missing timestamp")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid timestamp")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
diff := now - ts
|
|
||||||
if diff < 0 {
|
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
// 30 minute tolerance per agent.md.
|
|
||||||
if diff > 1800 {
|
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "request expired")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public endpoints require no token.
|
// Public endpoints require no token.
|
||||||
if targetLevel == "None" {
|
if targetLevel == "None" {
|
||||||
return true
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate X-Token.
|
|
||||||
token := r.Header.Get("X-Token")
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "missing token")
|
return http.StatusUnauthorized, "missing token"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenInfo, exists := GetTokenInfo(token)
|
tokenInfo, exists := GetTokenInfo(token)
|
||||||
if !exists {
|
if !exists {
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
|
return http.StatusUnauthorized, "invalid token"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission level.
|
// Check permission level.
|
||||||
@@ -164,21 +160,102 @@ func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLev
|
|||||||
switch targetLevel {
|
switch targetLevel {
|
||||||
case "admin":
|
case "admin":
|
||||||
if tokenInfo.Level != "admin" {
|
if tokenInfo.Level != "admin" {
|
||||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
return http.StatusForbidden, "permission denied"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
case "bot":
|
case "bot":
|
||||||
if tokenInfo.Level != "bot" && tokenInfo.Level != "admin" {
|
if tokenInfo.Level != "bot" && tokenInfo.Level != "admin" {
|
||||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
return http.StatusForbidden, "permission denied"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token last access time.
|
// Refresh token last access time.
|
||||||
RefreshToken(token)
|
RefreshToken(token)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is the central authentication and authorization function.
|
||||||
|
// It validates the request method, X-Timestamp header (30min tolerance),
|
||||||
|
// X-Token header, and permission level. Returns true if the request is authorized.
|
||||||
|
//
|
||||||
|
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||||
|
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||||
|
// When level is "admin", only "admin" tokens are accepted.
|
||||||
|
//
|
||||||
|
// WebSocket upgrades cannot carry custom headers from a browser; those endpoints
|
||||||
|
// use WebSocketAuthMiddleware instead, which also accepts the credentials as
|
||||||
|
// query parameters.
|
||||||
|
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
||||||
|
// Validate HTTP method.
|
||||||
|
if r.Method != targetMethod {
|
||||||
|
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate X-Timestamp.
|
||||||
|
if status, message := checkTimestamp(r.Header.Get("X-Timestamp")); status != 0 {
|
||||||
|
SendErrorResponse(w, status, message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate X-Token and its permission level.
|
||||||
|
if status, message := checkPermission(r.Header.Get("X-Token"), targetLevel); status != 0 {
|
||||||
|
SendErrorResponse(w, status, message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// AuthWS gates a WebSocket endpoint behind the given permission
|
||||||
|
// level ("bot" or "admin"), authenticating the upgrade request before the
|
||||||
|
// connection is handed to the handler. Unauthorized requests are answered with
|
||||||
|
// the standard JSON error response and are never upgraded.
|
||||||
|
//
|
||||||
|
// A browser cannot set custom headers on a WebSocket handshake, so the session
|
||||||
|
// token and timestamp are read from the X-Token / X-Timestamp headers when
|
||||||
|
// present and otherwise from the "token" and "timestamp" query parameters:
|
||||||
|
//
|
||||||
|
// ws://host/api/system/getLogs?token=<token>×tamp=<unix seconds>
|
||||||
|
//
|
||||||
|
// The timestamp is only checked at handshake time, so a long-lived connection
|
||||||
|
// stays open past its tolerance window. Because a query string commonly ends up
|
||||||
|
// in proxy and access logs, a token-carrying URL should be treated as a secret.
|
||||||
|
func AuthWS(targetLevel string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// An upgrade request is always a GET.
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers win over query parameters so programmatic clients can keep
|
||||||
|
// the credentials out of the URL.
|
||||||
|
token := r.Header.Get("X-Token")
|
||||||
|
timestamp := r.Header.Get("X-Timestamp")
|
||||||
|
query := r.URL.Query()
|
||||||
|
if token == "" {
|
||||||
|
token = query.Get("token")
|
||||||
|
}
|
||||||
|
if timestamp == "" {
|
||||||
|
timestamp = query.Get("timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, message := checkTimestamp(timestamp); status != 0 {
|
||||||
|
SendErrorResponse(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := checkPermission(token, targetLevel); status != 0 {
|
||||||
|
SendErrorResponse(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CleanExpiredTokens removes tokens that have been idle for over 1 hour.
|
// CleanExpiredTokens removes tokens that have been idle for over 1 hour.
|
||||||
func CleanExpiredTokens() {
|
func CleanExpiredTokens() {
|
||||||
tokenStoreLock.Lock()
|
tokenStoreLock.Lock()
|
||||||
|
|||||||
Reference in new issue
Block a user