Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5587d0c8 | ||
|
|
2524e40566 | ||
|
|
5167d799bc | ||
|
|
cb2df5076d | ||
|
|
2d5cf39151 | ||
|
|
05dcc3769d | ||
|
|
f36f17bd2a | ||
|
|
3877c6d128 | ||
|
|
3ce42b3cca | ||
|
|
057d7d584d | ||
|
|
90ca08066b | ||
|
|
d481f13d1d | ||
|
|
7ecf51eba5 | ||
|
|
59c0f5d5d8 | ||
|
|
16455027ec | ||
|
|
4be6d2add0 | ||
|
|
15144fb9d4 |
@@ -0,0 +1,9 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# cmd.exe misparses a batch file whose lines end in a bare LF: it loses
|
||||||
|
# characters at the start of later lines, so a working script silently turns
|
||||||
|
# into "command not recognized" errors. Force CRLF on checkout.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
|
||||||
|
# The mirror image: a CR at the end of a shebang or line breaks these.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -48,10 +48,10 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
# --frontend builds the Vue app inside the script. The second call omits
|
# Each script builds the Vue console itself and then compiles it into the
|
||||||
# it: the frontend is platform independent and dist/ is already built.
|
# binary (web/embed.go), so the uploaded executables are self-contained.
|
||||||
- name: Build Linux (amd64)
|
- name: Build Linux (amd64)
|
||||||
run: ${{ matrix.build_linux }} --frontend
|
run: ${{ matrix.build_linux }}
|
||||||
|
|
||||||
- name: Build Windows (amd64)
|
- name: Build Windows (amd64)
|
||||||
run: ${{ matrix.build_windows }}
|
run: ${{ matrix.build_windows }}
|
||||||
@@ -64,14 +64,3 @@ jobs:
|
|||||||
nukumizu-linux-amd64
|
nukumizu-linux-amd64
|
||||||
nukumizu-windows-amd64.exe
|
nukumizu-windows-amd64.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# The binaries read frontend/dist at runtime (os.DirFS in web/embed.go),
|
|
||||||
# they do not embed it. Uploaded once — the frontend is platform
|
|
||||||
# independent, so both matrix legs produce the same files.
|
|
||||||
- name: Upload frontend bundle
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: nukumizu-frontend
|
|
||||||
path: frontend/dist
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ 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
|
||||||
|
# build-*.sh / build-*.bat scripts rebuild it before every compile.
|
||||||
|
/web/dist/
|
||||||
|
/frontend/dist/
|
||||||
|
|
||||||
# SQLite database files. They live on a network share (Y:), and git/cloud
|
# SQLite database files. They live on a network share (Y:), and git/cloud
|
||||||
# sync touching them while a WAL database is open corrupts the WAL index and
|
# sync touching them while a WAL database is open corrupts the WAL index and
|
||||||
# crashes the process (EXCEPTION_IN_PAGE_ERROR). Never track these.
|
# crashes the process (EXCEPTION_IN_PAGE_ERROR). Never track these.
|
||||||
|
|||||||
@@ -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 Go server serves the built bundle from `frontend/dist`.
|
- **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,19 +41,22 @@ 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 # Locates the built console (frontend/dist)
|
│ ├── embed.go # Embeds the built console (web/dist) in the binary
|
||||||
|
│ ├── dist/ # Vite build output — generated, gitignored
|
||||||
│ └── handler.go # Static file serving + SPA fallback
|
│ └── handler.go # Static file serving + SPA fallback
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── komari/
|
│ ├── komari/
|
||||||
@@ -65,14 +69,14 @@ 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_napcat/
|
||||||
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||||
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||||
@@ -88,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
|
||||||
@@ -138,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",
|
||||||
@@ -150,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,
|
||||||
@@ -167,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": "",
|
||||||
@@ -175,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,11 +219,13 @@ 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, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||||
|
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
|
||||||
|
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
|
||||||
- `controllerMethod.qq(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`
|
||||||
|
|
||||||
@@ -255,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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -286,6 +309,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,14 +323,55 @@ Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any aut
|
|||||||
| `/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. |
|
||||||
|
| `/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
|
||||||
|
|
||||||
@@ -340,11 +406,13 @@ 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+.
|
||||||
|
|
||||||
Helper scripts in the repo root bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
|
Helper scripts in the repo root build the Vue console first, then compile the backend with it embedded. They also bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
|
||||||
|
|
||||||
| Script | Output |
|
| Script | Output |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -361,17 +429,17 @@ build-linux-x86_64.bat
|
|||||||
build-win-x86_64.bat
|
build-win-x86_64.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass `--frontend` to build the Vue console first (`npm ci` + `npm run build` inside `frontend/`); without it only the backend is compiled:
|
The console is **embedded in the binary**. Vite writes it to `web/dist` and `web/embed.go` compiles that directory in with `go:embed`, so the executable serves the whole frontend on its own — copy it anywhere, with neither `frontend/` nor `web/dist` next to it, and `/` still returns the console. The build scripts run `npm ci` when `frontend/node_modules` is missing and `npm run build` on every run, so they need Node.js 22+ on the build machine (not on the machine that runs the binary).
|
||||||
|
|
||||||
```bash
|
Building the backend therefore requires the console to have been built at least once: `web/dist` is a generated, gitignored directory, and `go build` fails with `pattern all:dist: no matching files found` until it exists. Any `build-*` script handles that ordering for you.
|
||||||
./build-linux-x86_64.sh --frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
The console is **not embedded in the binary** — `web/` reads `frontend/dist` from disk at runtime. A binary built without `--frontend` still starts and serves the API, but `/` answers `500 index.html not found` until a built `frontend/dist` sits in the working directory. Build it once with `--frontend`, then re-run any of the four scripts without the flag.
|
|
||||||
|
|
||||||
Equivalent manual builds:
|
Equivalent manual builds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Console (once per frontend change)
|
||||||
|
cd frontend && npm ci && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. Backend
|
||||||
# Linux / macOS
|
# Linux / macOS
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
@@ -385,7 +453,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
|||||||
|
|
||||||
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
||||||
|
|
||||||
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two binaries plus the frontend bundle as artifacts.
|
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two self-contained binaries as artifacts.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@@ -403,7 +471,7 @@ On startup the program logs in to Komari, loads node state, connects the status
|
|||||||
|
|
||||||
### Frontend development
|
### Frontend development
|
||||||
|
|
||||||
`run.bat` only runs the Go backend — it does not build the console. While working on the frontend, run the two sides separately:
|
`run.bat` only runs the Go backend — it does not build the console, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or `go run` fails to compile. While working on the frontend, run the two sides separately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 — backend (API + WebSocket) on :8080
|
# Terminal 1 — backend (API + WebSocket) on :8080
|
||||||
@@ -421,7 +489,7 @@ Open http://localhost:5173. The dev server proxies `/api` — including the log
|
|||||||
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
|
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
For a production build the Go server serves `frontend/dist` itself, on the normal listen address — see [Building](#building).
|
For a production build the Go server serves the embedded console itself, on the normal listen address — see [Building](#building). Vite is not involved at runtime, so `npm run build` alone does not change what a running binary serves: rebuild the binary to pick up frontend changes.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -4,21 +4,33 @@ setlocal enabledelayedexpansion
|
|||||||
:: Build from the repository root, however the script was invoked.
|
:: Build from the repository root, however the script was invoked.
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
:: Optional first argument: --frontend also builds the Vue frontend, which web/
|
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||||
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
|
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||||
set BUILD_FRONTEND=0
|
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||||
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
|
|
||||||
|
|
||||||
if not "%BUILD_FRONTEND%"=="1" goto :backend
|
|
||||||
|
|
||||||
echo Building frontend...
|
echo Building frontend...
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
|
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||||
|
:: the lockfile; a warm tree only rebuilds.
|
||||||
|
if not exist "node_modules" (
|
||||||
call npm ci
|
call npm ci
|
||||||
if errorlevel 1 goto :fail
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
)
|
||||||
|
|
||||||
call npm run build
|
call npm run build
|
||||||
if errorlevel 1 goto :fail
|
if errorlevel 1 goto :frontend_failed
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||||
|
if exist "web\dist\index.html" goto :backend
|
||||||
|
echo Frontend build produced no web\dist\index.html.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:frontend_failed
|
||||||
|
cd ..
|
||||||
|
echo Frontend build failed.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
:backend
|
:backend
|
||||||
echo Building for Linux (amd64)...
|
echo Building for Linux (amd64)...
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -1,27 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Builds the Linux (amd64) binary.
|
# Builds the Linux (amd64) binary.
|
||||||
# ./build-linux-x86_64.sh backend only
|
|
||||||
# ./build-linux-x86_64.sh --frontend also build the Vue frontend
|
|
||||||
#
|
#
|
||||||
# The frontend is needed at runtime: web/ serves it from frontend/dist.
|
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||||
|
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||||
|
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Build from the repository root, however the script was invoked.
|
# Build from the repository root, however the script was invoked.
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
BUILD_FRONTEND=0
|
|
||||||
if [ "${1:-}" = "--frontend" ]; then
|
|
||||||
BUILD_FRONTEND=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BUILD_FRONTEND" = "1" ]; then
|
|
||||||
echo "Building frontend..."
|
echo "Building frontend..."
|
||||||
(
|
(
|
||||||
cd frontend
|
cd frontend
|
||||||
|
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||||
|
# from the lockfile; a warm tree only rebuilds.
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
npm ci
|
npm ci
|
||||||
|
fi
|
||||||
npm run build
|
npm run build
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||||
|
if [ ! -f web/dist/index.html ]; then
|
||||||
|
echo "Frontend build produced no web/dist/index.html" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building for Linux (amd64)..."
|
echo "Building for Linux (amd64)..."
|
||||||
|
|||||||
+21
-9
@@ -4,21 +4,33 @@ setlocal enabledelayedexpansion
|
|||||||
:: Build from the repository root, however the script was invoked.
|
:: Build from the repository root, however the script was invoked.
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
:: Optional first argument: --frontend also builds the Vue frontend, which web/
|
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||||
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
|
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||||
set BUILD_FRONTEND=0
|
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||||
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
|
|
||||||
|
|
||||||
if not "%BUILD_FRONTEND%"=="1" goto :backend
|
|
||||||
|
|
||||||
echo Building frontend...
|
echo Building frontend...
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
|
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||||
|
:: the lockfile; a warm tree only rebuilds.
|
||||||
|
if not exist "node_modules" (
|
||||||
call npm ci
|
call npm ci
|
||||||
if errorlevel 1 goto :fail
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
)
|
||||||
|
|
||||||
call npm run build
|
call npm run build
|
||||||
if errorlevel 1 goto :fail
|
if errorlevel 1 goto :frontend_failed
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||||
|
if exist "web\dist\index.html" goto :backend
|
||||||
|
echo Frontend build produced no web\dist\index.html.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:frontend_failed
|
||||||
|
cd ..
|
||||||
|
echo Frontend build failed.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
:backend
|
:backend
|
||||||
echo Building for Windows (amd64)...
|
echo Building for Windows (amd64)...
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -1,27 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Builds the Windows (amd64) binary.
|
# Builds the Windows (amd64) binary.
|
||||||
# ./build-win-x86_64.sh backend only
|
|
||||||
# ./build-win-x86_64.sh --frontend also build the Vue frontend
|
|
||||||
#
|
#
|
||||||
# The frontend is needed at runtime: web/ serves it from frontend/dist.
|
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||||
|
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||||
|
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Build from the repository root, however the script was invoked.
|
# Build from the repository root, however the script was invoked.
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
BUILD_FRONTEND=0
|
|
||||||
if [ "${1:-}" = "--frontend" ]; then
|
|
||||||
BUILD_FRONTEND=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BUILD_FRONTEND" = "1" ]; then
|
|
||||||
echo "Building frontend..."
|
echo "Building frontend..."
|
||||||
(
|
(
|
||||||
cd frontend
|
cd frontend
|
||||||
|
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||||
|
# from the lockfile; a warm tree only rebuilds.
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
npm ci
|
npm ci
|
||||||
|
fi
|
||||||
npm run build
|
npm run build
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||||
|
if [ ! -f web/dist/index.html ]; then
|
||||||
|
echo "Frontend build produced no web/dist/index.html" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building for Windows (amd64)..."
|
echo "Building for Windows (amd64)..."
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## 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
|
||||||
|
- [Integrate frontend build into backend binary]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d")
|
||||||
|
|
||||||
|
|
||||||
|
## Ver.0.1.2.3-1c4ad61.pre-release
|
||||||
|
### Features
|
||||||
|
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
|
||||||
|
- [Add scripts for building frontend and backend for Linux and Windows]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6")
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- [Update API response structure to nest payloads under a single "data" key]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1")
|
||||||
|
|
||||||
|
|
||||||
|
## Ver.0.1.1.2-15f1ee3.pre-release
|
||||||
|
### Features
|
||||||
|
- [Add per-node notify switch controller]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-11
@@ -17,7 +17,7 @@ func LoadBotNodeConfig(configPath string) error {
|
|||||||
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,19 +27,17 @@ 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
|
||||||
}
|
}
|
||||||
@@ -107,6 +105,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 +144,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 +160,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +211,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
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -82,14 +82,21 @@ 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 {
|
||||||
|
settingsLock.Lock()
|
||||||
|
defer settingsLock.Unlock()
|
||||||
|
|
||||||
|
return updateSettingsLocked(settingsType, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSettingsLocked is UpdateSettings without the locking, for callers that
|
||||||
|
// need to inspect the loaded configuration and write in one critical section
|
||||||
|
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
|
||||||
|
func updateSettingsLocked(settingsType string, patch map[string]interface{}) error {
|
||||||
path, err := settingsPath(settingsType)
|
path, err := settingsPath(settingsType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsLock.Lock()
|
|
||||||
defer settingsLock.Unlock()
|
|
||||||
|
|
||||||
// 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.
|
||||||
current := map[string]interface{}{}
|
current := map[string]interface{}{}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-7
@@ -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 {
|
||||||
@@ -34,6 +37,7 @@ type KomariConfig struct {
|
|||||||
|
|
||||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
||||||
type QQConfig struct {
|
type QQConfig 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"`
|
NapcatAddr string `json:"napcatAddr"`
|
||||||
@@ -45,6 +49,7 @@ type QQConfig struct {
|
|||||||
|
|
||||||
// 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 +58,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 +72,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 +81,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"`
|
||||||
@@ -93,6 +103,50 @@ type ControllerMethodConfig struct {
|
|||||||
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(napcat)", "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.
|
||||||
type ControllerMessageConfig struct {
|
type ControllerMessageConfig struct {
|
||||||
BotStarted string `json:"BOT_STARTED"`
|
BotStarted string `json:"BOT_STARTED"`
|
||||||
@@ -108,13 +162,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 {
|
||||||
@@ -169,7 +236,16 @@ type BotUserConfig struct {
|
|||||||
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;
|
||||||
@@ -187,6 +263,18 @@ type BotNodeOptions struct {
|
|||||||
// options.
|
// options.
|
||||||
type BotNodeMembers map[string]BotNodeOptions
|
type BotNodeMembers map[string]BotNodeOptions
|
||||||
|
|
||||||
// C_botNodeConfig is the global singleton mirroring bot_node_config.json,
|
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig.
|
||||||
// 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,200 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsLock.Lock()
|
||||||
|
defer settingsLock.Unlock()
|
||||||
|
|
||||||
|
if _, exists := webhookEndpoint(name); exists {
|
||||||
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
||||||
|
}
|
||||||
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsLock.Lock()
|
||||||
|
defer settingsLock.Unlock()
|
||||||
|
|
||||||
|
if _, exists := webhookEndpoint(name); !exists {
|
||||||
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||||
|
}
|
||||||
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
||||||
|
// name. The endpoint stops accepting requests as soon as the configuration is
|
||||||
|
// reloaded.
|
||||||
|
func DeleteWebhookEndpoint(name string) error {
|
||||||
|
settingsLock.Lock()
|
||||||
|
defer settingsLock.Unlock()
|
||||||
|
|
||||||
|
if _, exists := webhookEndpoint(name); !exists {
|
||||||
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
||||||
|
// No extra lock is needed to read it: a reload replaces the whole configuration
|
||||||
|
// rather than mutating it in place, and Current publishes the replacement
|
||||||
|
// atomically, so the value read is always from one complete version.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+4
-4
@@ -24,14 +24,14 @@ The dev server proxies `/api` (and the log websocket) to the backend. By default
|
|||||||
$env:NUKUMIZU_API = "http://192.168.20.4:8080"; npm run dev
|
$env:NUKUMIZU_API = "http://192.168.20.4:8080"; npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Production build (static assets only):
|
Production build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # outputs dist/
|
npm run build # outputs ../web/dist
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
The backend does not serve static files, so put `dist/` behind any static server and proxy `/api` (and `ws://…/api/system/getLogs`) to the Nukumizu backend.
|
Vite writes to `../web/dist` rather than `frontend/dist` (see `build.outDir` in `vite.config.js`) because the Go backend embeds that directory into the binary — `go:embed` cannot reach outside the package it sits in, so the output has to live under `web/`. The repo's `build-*` scripts run this build for you and compile the backend afterwards; `npm run build` alone does not change what an already-built binary serves.
|
||||||
|
|
||||||
## API contract notes
|
## API contract notes
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -19,3 +19,16 @@ 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,228 @@
|
|||||||
|
<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 {
|
||||||
|
await settingsApi.set('global', patch);
|
||||||
|
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 {
|
||||||
|
|||||||
+14
-194
@@ -1,12 +1,12 @@
|
|||||||
<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',
|
||||||
@@ -65,6 +65,7 @@ const sections = [
|
|||||||
root: ['controllerMethod', 'qq(napcat)'],
|
root: ['controllerMethod', 'qq(napcat)'],
|
||||||
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: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
||||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
||||||
@@ -79,6 +80,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', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' },
|
||||||
{ 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 +92,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', 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: '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 +109,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', 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: '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 +120,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', help: 'Format the alert body with code blocks and inline code in the posted payload.' },
|
||||||
{ 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 +146,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 +161,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>
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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(napcat)', label: 'QQ' },
|
||||||
|
{ 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>
|
||||||
@@ -6,6 +6,16 @@ const BACKEND = process.env.NUKUMIZU_API || 'http://127.0.0.1:8080';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
// web/embed.go embeds this directory into the Go binary. It has to live
|
||||||
|
// inside the web package: go:embed cannot reach outside its own
|
||||||
|
// directory, so ../web/dist is as close as it gets.
|
||||||
|
outDir: '../web/dist',
|
||||||
|
// The directory is outside the project root, so Vite refuses to empty
|
||||||
|
// it unless told to. Without this, stale hashed assets from earlier
|
||||||
|
// builds pile up in the binary.
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
+2
-2
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
|
|||||||
|
|
||||||
var SoftwareInfo = SoftwareInfoStr{
|
var SoftwareInfo = SoftwareInfoStr{
|
||||||
Name: "Nukumizu",
|
Name: "Nukumizu",
|
||||||
Version: "0.1.1",
|
Version: "0.2.0",
|
||||||
Developer: "Madobi Nanami",
|
Developer: "Madobi Nanami",
|
||||||
BuildVer: 2,
|
BuildVer: 5,
|
||||||
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) {
|
||||||
|
|||||||
@@ -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,7 +1,9 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
@@ -12,7 +14,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
|
||||||
@@ -38,8 +40,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,9 +85,15 @@ 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
|
||||||
@@ -105,14 +138,14 @@ func (m *Manager) Register(c Controller) {
|
|||||||
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
||||||
// not implement BotController are skipped. 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 +157,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 +170,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 +189,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 {
|
||||||
@@ -188,6 +221,59 @@ func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// StopAll stops all registered controllers.
|
||||||
func (m *Manager) StopAll() {
|
func (m *Manager) StopAll() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
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"
|
||||||
@@ -54,6 +55,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 +71,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 +85,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 +98,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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -52,15 +53,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 +79,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 +92,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 == "" {
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import (
|
|||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// actionLogEnabled reports whether the NapCat HTTP API methods echo each action
|
||||||
|
// they send, per the showNapcatAction 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.ShowNapcatAction
|
||||||
|
}
|
||||||
|
|
||||||
// APIResponse mirrors NapCat's HTTP API response envelope.
|
// APIResponse mirrors NapCat's HTTP API response envelope.
|
||||||
type APIResponse struct {
|
type APIResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -155,7 +164,7 @@ func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bo
|
|||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
if actionLogEnabled() {
|
||||||
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
|
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +185,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
|
|||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
if actionLogEnabled() {
|
||||||
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
|
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +199,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
|
|||||||
|
|
||||||
// GetGroupList retrieves the list of joined groups from NapCat.
|
// GetGroupList retrieves the list of joined groups from NapCat.
|
||||||
func (c *Client) GetGroupList() (*APIResponse, error) {
|
func (c *Client) GetGroupList() (*APIResponse, error) {
|
||||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
if actionLogEnabled() {
|
||||||
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
|
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +220,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
|
|||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
if actionLogEnabled() {
|
||||||
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
|
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +234,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
|
|||||||
|
|
||||||
// GetFriendsList retrieves the friends list from NapCat.
|
// GetFriendsList retrieves the friends list from NapCat.
|
||||||
func (c *Client) GetFriendsList() (*APIResponse, error) {
|
func (c *Client) GetFriendsList() (*APIResponse, error) {
|
||||||
if config.C_globalConfig.Debug.ShowNapcatAction {
|
if actionLogEnabled() {
|
||||||
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
|
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ func (q *QQController) IsEnabled() bool {
|
|||||||
return q.cfg.Enabled
|
return q.cfg.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMarkdown returns whether the channel renders Markdown, per its markdown
|
||||||
|
// setting in config.json.
|
||||||
|
func (q *QQController) IsMarkdown() bool {
|
||||||
|
return q.cfg.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
||||||
func (q *QQController) handleNapcatEvent(raw []byte) {
|
func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||||
var ev oneBotEvent
|
var ev oneBotEvent
|
||||||
@@ -94,24 +100,34 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
|||||||
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
|
||||||
|
// 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.ShowNapcatAction
|
||||||
|
|
||||||
|
if debugMode && cfg.Debug.ShowNapcatMsg {
|
||||||
postLog.Debug("Napcat WS event received: " + string(raw))
|
postLog.Debug("Napcat 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("Ignoring Napcat 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.NapcatIgnoreSelfMsg {
|
||||||
if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction {
|
if showAction {
|
||||||
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -132,7 +148,7 @@ 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("Napcat WS command discarded: " + string(raw))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -154,6 +170,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,7 +179,7 @@ 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.ShowNapcatAction {
|
||||||
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
||||||
}
|
}
|
||||||
return "" // Not mentioned, ignore.
|
return "" // Not mentioned, ignore.
|
||||||
@@ -179,12 +196,12 @@ 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_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,7 +224,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 +232,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 +248,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 +271,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 +300,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 +316,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,6 +326,20 @@ 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.napcatClient == nil {
|
||||||
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -53,15 +54,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,9 +87,9 @@ 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",
|
||||||
@@ -101,9 +108,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 +125,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) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -20,7 +20,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +142,12 @@ 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
|
||||||
|
// values that are read verbatim.
|
||||||
result = strings.ReplaceAll(result, "{{ time }}", "**"+params.Time+"**")
|
result = strings.ReplaceAll(result, "{{ time }}", "**"+params.Time+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ serverName }}", "**"+params.ServerName+"**")
|
result = strings.ReplaceAll(result, "{{ serverName }}", "**"+params.ServerName+"**")
|
||||||
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`"+params.ServerUUID+"`")
|
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`"+params.ServerUUID+"`")
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,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,9 +170,38 @@ func main() {
|
|||||||
postLog.Info("Server stopped")
|
postLog.Info("Server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startWebhookServer serves the incoming webhook API on its own listener. The
|
||||||
|
// API is not exposed on the main listener: external applications post alerts to
|
||||||
|
// this port only, so its rate limiter and CORS policy are configured
|
||||||
|
// independently. A failure to bind it is logged rather than fatal — the rest of
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := utils.RateLimitMiddleware(SetupWebhookRouter())
|
||||||
|
handler = utils.CORSMiddleware(handler)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%s", cfg.Webhook.ListenAddr, cfg.Webhook.ListenPort)
|
||||||
|
postLog.Info(fmt.Sprintf("Webhook API listening on %s", addr))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
postLog.Error(fmt.Sprintf("Webhook server panic: %v", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||||
|
postLog.Error("Webhook server error: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// initControllers initializes and starts all configured controllers.
|
// initControllers initializes and starts all configured controllers.
|
||||||
func initControllers() {
|
func initControllers() {
|
||||||
cfg := config.C_globalConfig
|
cfg := config.Current()
|
||||||
mgr := controller.GetManager()
|
mgr := controller.GetManager()
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: 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.
|
||||||
|
if not exist "web\dist\index.html" (
|
||||||
|
echo The web console is not built: web\dist is missing.
|
||||||
|
echo Run build-win-x86_64.bat once, or "npm run build" in frontend\.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||||
|
|
||||||
|
|||||||
+111
-34
@@ -100,32 +100,22 @@ 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.
|
||||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
func checkTimestamp(timestamp string) (int, string) {
|
||||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
if config.IsDebugMode() {
|
||||||
// When level is "admin", only "admin" tokens are accepted.
|
return 0, ""
|
||||||
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.
|
|
||||||
timestamp := r.Header.Get("X-Timestamp")
|
|
||||||
if !config.IsDebugMode() {
|
|
||||||
if timestamp == "" {
|
if timestamp == "" {
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "missing timestamp")
|
return http.StatusUnauthorized, "missing timestamp"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid timestamp")
|
return http.StatusUnauthorized, "invalid timestamp"
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
@@ -133,29 +123,35 @@ func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLev
|
|||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
// 30 minute tolerance per agent.md.
|
|
||||||
if diff > 1800 {
|
if diff > 1800 {
|
||||||
SendErrorResponse(w, http.StatusUnauthorized, "request expired")
|
return http.StatusUnauthorized, "request expired"
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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".
|
||||||
|
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||||
|
// When level is "admin", only "admin" tokens are accepted.
|
||||||
|
//
|
||||||
|
// It returns 0 when the token is authorized, otherwise the HTTP status and
|
||||||
|
// message to reject the request with.
|
||||||
|
func checkPermission(token string, targetLevel string) (int, string) {
|
||||||
// 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()
|
||||||
|
|||||||
+24
-12
@@ -1,23 +1,35 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StaticFiles is the built frontend, rooted at the directory Vite writes to
|
// distFS holds the built console. Vite writes it to web/dist (see the outDir
|
||||||
// (frontend/dist), so paths inside it are relative to that directory, e.g.
|
// in frontend/vite.config.js) and it is compiled into the binary here, so a
|
||||||
// "index.html" or "assets/app.js". The path is relative to the working
|
// running executable serves the whole frontend on its own: neither the
|
||||||
// directory, so the server is expected to run from the repository root.
|
// frontend sources nor web/dist need to exist on the machine that runs it.
|
||||||
|
//
|
||||||
|
// The all: prefix also picks up files whose names start with "_" or ".", which
|
||||||
|
// the default pattern skips.
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
// StaticFiles is the built frontend, rooted at the directory Vite writes to,
|
||||||
|
// so paths inside it are relative to that directory, e.g. "index.html" or
|
||||||
|
// "assets/app.js".
|
||||||
|
//
|
||||||
|
// web/dist is a build artifact and is not in a fresh checkout, so the console
|
||||||
|
// has to be built before the backend compiles — any build-*.sh / build-*.bat
|
||||||
|
// does it first, or run `npm run build` in frontend/ yourself. Compiling
|
||||||
|
// without it fails with "pattern all:dist: no matching files found".
|
||||||
var StaticFiles fs.FS
|
var StaticFiles fs.FS
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
dir := filepath.Join("frontend", "dist")
|
sub, err := fs.Sub(distFS, "dist")
|
||||||
if _, err := os.Stat(dir); err == nil {
|
if err != nil {
|
||||||
StaticFiles = os.DirFS(dir)
|
panic("web: embedded frontend is unreadable: " + err.Error())
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
StaticFiles = sub
|
||||||
StaticFiles = os.DirFS(".")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-8
@@ -7,13 +7,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var staticFS http.FileSystem
|
// staticFS wraps the embedded frontend for net/http. StaticFiles is already
|
||||||
|
// rooted at the directory Vite writes to, so it is served as-is — no further
|
||||||
// This init runs after embed.go's, which sets StaticFiles (Go initializes a
|
// fs.Sub is needed. http.FS copies a file that is not an io.Seeker into memory
|
||||||
// package's files in lexical file-name order). StaticFiles is already rooted
|
// before serving it, which keeps this working whatever fs.FS StaticFiles is.
|
||||||
// at frontend/dist, so it is served as-is — no further fs.Sub is needed.
|
func staticFS() http.FileSystem {
|
||||||
func init() {
|
return http.FS(StaticFiles)
|
||||||
staticFS = http.FS(StaticFiles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeStatic(w http.ResponseWriter, r *http.Request) {
|
func ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -25,7 +24,7 @@ func ServeStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filePath := strings.TrimPrefix(urlPath, "/")
|
filePath := strings.TrimPrefix(urlPath, "/")
|
||||||
f, err := staticFS.Open(filePath)
|
f, err := staticFS().Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serveIndexHTML(w)
|
serveIndexHTML(w)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user