18 Commits
Author SHA1 Message Date
NanamiAdmin 8c2159ff0c chore(variables): update version and build number to 0.2.2 and 7 respectively
Build / ubuntu-latest (push) Failing after 9s
Build / windows-latest (push) Canceled after 6m3s
2026-10-13 20:46:48 +08:00
NanamiAdmin bc5cb417eb refactor(overview, settings): remove some unnecessary hints and help
Build / ubuntu-latest (push) Canceled after 1m2s
Build / windows-latest (push) Canceled after 1m13s
2026-10-13 20:45:43 +08:00
NanamiAdmin 1a7a4cdc07 feat(node): implement flap tolerance settings and options dialog in frontend
Build / windows-latest (push) Failing after 1m15s
Build / ubuntu-latest (push) Failing after 3m5s
2026-10-13 20:39:05 +08:00
NanamiAdmin 9137d53286 feat(debug): add ShowFlapTolerantEcho flag for enhanced logging control
Build / ubuntu-latest (push) Failing after 3m0s
Build / windows-latest (push) Failing after 3m47s
2026-10-12 17:34:55 +08:00
NanamiAdmin 03e74f49c3 feat(controller): add flap inhibition for each controllers
Build / ubuntu-latest (push) Failing after 3m3s
Build / windows-latest (push) Failing after 11m25s
2026-10-12 17:17:55 +08:00
NanamiAdmin 0c581abc4c refactor(qq_onebot): replace all http handler with a brand new websocket handler, seperate with msg recver
Build / ubuntu-latest (push) Failing after 3m8s
Build / windows-latest (push) Failing after 56m41s
2026-10-11 15:28:19 +08:00
NanamiAdmin ba0d8539b3 refactor(qq_onebot): update log messages to use consistent prefix
Build / windows-latest (push) Failing after 3m7s
Build / ubuntu-latest (push) Failing after 3m53s
2026-10-11 14:36:59 +08:00
NanamiAdmin 1b11be2427 style(qq_onebot): rename all NapCat related to OneBot
Build / ubuntu-latest (push) Failing after 3m56s
Build / windows-latest (push) Canceled after 34m52s
2026-10-11 13:58:26 +08:00
NanamiAdmin 72d0d587cb feat(run): add script to run server with build metadata
Build / ubuntu-latest (push) Failing after 3m1s
Build / windows-latest (push) Failing after 33m0s
2026-10-04 15:27:42 +08:00
NanamiAdmin c5a80bd952 docs(changelog): update version format for 0.2.1.6 pre-release
Build / ubuntu-latest (push) Failing after 5m44s
Build / windows-latest (push) Canceled after 24m16s
2026-10-04 15:05:24 +08:00
NanamiAdmin d06d0bcd2f docs(changelog): add 0.2.1.6 changelog
Build / ubuntu-latest (push) Failing after 3m6s
Build / windows-latest (push) Failing after 52m38s
2026-09-29 21:51:47 +08:00
NanamiAdmin aa7e7f87e1 feat(node): add GetNodeCount to get node count and remove no need node update log, only log when node count changed
Build / ubuntu-latest (push) Failing after 3m17s
Build / windows-latest (push) Canceled after 8m6s
2026-09-29 21:43:46 +08:00
NanamiAdmin c7642ecca5 feat(settings): report which saved keys need a restart
Build / ubuntu-latest (push) Failing after 52s
Build / windows-latest (push) Failing after 41m49s
Every settings update answered with a bare success, so the console could not
tell a change that took effect at once from one written to the file that the
running program would keep ignoring until it was restarted — the listener
address, the storage paths, the Komari dashboard URL.

Classify the patch instead. config.RestartRequiredKeys expands a patch into the
dot-separated paths of its leaves and intersects them with the settings main
reads before it starts serving, and the settings endpoint returns that list as
data.restartRequired. It is a pure function of the patch, so UpdateSettings
keeps its signature and nothing else has to change; the write succeeds either
way, and the field only says which edits are not live.

Matching is by overlap rather than equality, so a patch that names a section —
replacing it, or deleting it with a null — reports the startup-only keys inside
it, while a sibling subtree like webhook.endpoints is not mistaken for
webhook.enabled.

The result is never nil, so an update with nothing to report serializes as []
rather than null and a client can iterate it without a guard.

The console reads the field in ConfigSection and warns instead of confirming,
naming the keys that are pending; Settings.vue's hints for the Komari URL, the
listen address and the storage paths now say which of their fields is affected
rather than labelling the whole card.
2026-09-28 23:28:47 +08:00
NanamiAdmin 457ea42417 fix(netproxy): resolve the proxy address per request
Build / ubuntu-latest (push) Failing after 3m3s
Build / windows-latest (push) Canceled after 3m19s
ProxyFunc parsed system.networkProxy once, when a client was built, and baked
the result into the transport. Editing the proxy address therefore had no effect
on any client that already existed: ntfy, the outgoing webhook, NapCat's HTTP
API, Telegram's polling and the NapCat WebSocket dialer all kept the address
they were built with, and Email's SMTP dialer did the same. Only a controller
rebuild would pick up a new one, and that fires only when controllerMethod
changes — so the address was effectively fixed until a restart.

Resolve it inside the returned function instead. A transport proxy function that
returns a nil URL asks for a direct connection, so this also covers clearing the
setting: a channel built while a proxy was configured now falls back to dialing
directly rather than retrying a dead address.

Opting out is now the only case where ProxyFunc returns nil. That is deliberate:
a function resolved to nothing at construction time is exactly what pinned the
address in the first place.

DialWithTimeout reads the address per dial for the same reason, which is what
lets the gomail NetDialTimeout hook follow a settings change.
2026-09-28 23:25:13 +08:00
NanamiAdmin 7dc6089871 docs(readme): correct how system.networkProxy takes effect
Build / ubuntu-latest (push) Canceled after 3m11s
Build / windows-latest (push) Canceled after 3m23s
The rebuild only fires when the controllerMethod section changed, so editing
networkProxy on its own does not rebuild anything and the channels keep the
proxy they connected with. The previous wording read as though a rebuild would
follow on its own.
2026-09-28 23:22:07 +08:00
NanamiAdmin 2a5a46a984 refactor(controller): apply controller settings by rebuilding the channel set
Build / ubuntu-latest (push) Canceled after 20s
Build / windows-latest (push) Canceled after 24s
Giving each controller a Reload method worked for the notification pipes, which
only hold values, but not for the two bot channels: the NapCat client is stopped
through a sync.Once and the Telegram polling context is created with the
controller, so neither can be re-pointed once running. It also meant five
bespoke implementations of the same idea.

Replace the whole set instead. Manager.ReplaceAll swaps in a freshly built set
under the registry lock, stops the outgoing controllers outside it, and starts
the incoming ones off the calling goroutine so a slow handshake does not hold
the settings request open. ReplaceAll is now the only way controllers are
installed: Register is gone, because a lone registration would slip a controller
in without recording the settings it was built from, which is what NeedsRebuild
compares against.

The rebuild runs from the reload hook and only fires when the controllerMethod
section actually changed, so saving a message template or a webhook endpoint
leaves the channels alone. NeedsRebuild compares the whole section by value, and
a test pins that reloading a file reproduces it exactly — a default applied
inconsistently would make every save look like a controller change and tear down
every channel.

With controllers immutable after construction and replaced wholesale, the atomic
pointers and Reload methods have no writers left, so they are gone and the pipes
return to plain fields. notifyReload now serializes hook execution for the same
reason: a hook mutates process-wide state, and two overlapping settings updates
would otherwise each decide to rebuild from their own view of what was applied.

Kept from that work: applyEmailProxy restores gomail's default dialer when
networkUseProxy is turned off. The old constructor only ever installed the proxy
dialer, so switching the flag back left SMTP tunnelled through a proxy with no
way to undo it short of a restart.

Documents the resulting behaviour in the README under "What applies without a
restart".
2026-09-28 23:21:35 +08:00
NanamiAdmin 0691e85ccf feat(controller): hot-reload the notification pipes
Build / ubuntu-latest (push) Failing after 3m13s
Build / windows-latest (push) Canceled after 6m23s
Email, ntfy and webhook kept their settings in a plain struct field copied at
construction, so editing controllerMethod in config.json wrote the file and
replaced the in-memory configuration while the running channels stayed on their
boot values.

Add Reload to the Controller interface and implement it for the three
notification pipes; Manager.ReloadAll fans an update out to every registered
controller and is wired into the reload hook from main.

Reload runs on the goroutine serving the settings update while the send methods
run on the status-change and incoming-webhook goroutines, so storing the new
settings in a plain field would be a data race. Each pipe publishes its settings
— and the HTTP client derived from them — through atomic pointers, and every
send takes one snapshot so a reload landing mid-send cannot split it across two
configurations.

Only a change to networkUseProxy rebuilds the client; everything else is read
from the settings at send time, so a reload that changes nothing relevant leaves
the client alone. Email needed one more fix: gomail exposes its dial hook as a
package-level variable with no unset, and the constructor only installed the
proxy dialer when the flag was set, so turning networkUseProxy off left SMTP
tunnelled through a proxy. applyEmailProxy now restores net.DialTimeout, which
is gomail's own default.

QQ (Napcat) and Telegram implement Reload because the interface requires it, but
neither can apply a change in place: the NapCat client is stopped through a
sync.Once and the Telegram polling context is created with the controller, so
both need to be rebuilt and swapped into the manager. Rather than no-op silently
and let an edit look applied, they log a warning while their settings diverge.
That rebuild is the next step.
2026-09-28 23:15:11 +08:00
NanamiAdmin d9a9918e21 feat(config): notify reload hooks after a settings update
Build / ubuntu-latest (push) Failing after 3m8s
Build / windows-latest (push) Canceled after 4m3s
A settings update replaced the in-memory configuration but nothing told the code
that derives state from it. The logger is the first such consumer: its debug
flag is captured once at startup by postLog.SetDebugMode, so toggling
system.debugMode at runtime changed what config.IsDebugMode() reported while
DEBUG lines stayed filtered by the value from boot.

Add a hook registry to the config package. OnReload registers a callback and
every writer of config.json runs the registered hooks with the configuration now
in effect. It lives in config so packages that already depend on it (the logger,
and later the controller manager) can react without config importing them back,
which would be an import cycle.

Hooks run after settingsLock is released, not inside it. They will rebuild
controllers once those support reloading, which can wait on a network call, and
settingsLock is also held by the node tracker's background save — running a hook
under the lock would stall node registration behind a settings edit.
runSettingsUpdate now owns that lock/notify sequence for all four writer paths
(UpdateSettings and the three webhook endpoint helpers).

A panicking hook is logged and skipped: by then the new configuration is on disk
and published, so reporting the write as failed would be a lie, and the hooks
registered after the broken one still need to run.
2026-09-28 23:10:33 +08:00
38 changed files with 2959 additions and 579 deletions

No files matched your search

+18 -6
View File
@@ -77,9 +77,9 @@ nukumizu-backend/
│ ├── email.go # Email notification pipe │ ├── email.go # Email notification pipe
│ ├── ntfy.go # ntfy notification pipe │ ├── ntfy.go # ntfy notification pipe
│ ├── webhook.go # Outgoing webhook notification pipe │ ├── webhook.go # Outgoing webhook notification pipe
│ ├── qq_napcat/ │ ├── qq_onebot/
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller │ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
│ │ └── napcat.go # NapCat WebSocket + HTTP API client │ │ └── onebot.go # OneBot 11 WebSocket client (events + API)
│ └── telegram/ │ └── telegram/
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling) │ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
│ └── send.go # Message sending / splitting (Telegram Markdown) │ └── send.go # Message sending / splitting (Telegram Markdown)
@@ -101,7 +101,7 @@ nukumizu-backend/
- Go **1.25** or newer - Go **1.25** or newer
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it - [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host - A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint - For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 forward WebSocket endpoint (`/api` and `/event`)
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather) - For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`. Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
@@ -218,7 +218,7 @@ There are two configuration files, both read from the working directory unless o
Field notes: Field notes:
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT). - `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, QQ OneBot WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent. - `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. - `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).
@@ -294,6 +294,18 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
| `{{ list.offlineServers }}` | Formatted list of offline servers | | `{{ list.offlineServers }}` | Formatted list of offline servers |
| `{{ softwareVersion }}`, `{{ softwareBuildVer }}`, `{{ softwareCommitHash }}`, `{{ softwareBuildType }}`, `{{ softwareBuildTime }}`, `{{ softwareDeveloper }}`, `{{ softwareDescription }}` | Build metadata (commit hash and build time are injected at compile time) | | `{{ softwareVersion }}`, `{{ softwareBuildVer }}`, `{{ softwareCommitHash }}`, `{{ softwareBuildType }}`, `{{ softwareBuildTime }}`, `{{ softwareDeveloper }}`, `{{ softwareDescription }}` | Build metadata (commit hash and build time are injected at compile time) |
### What applies without a restart
Saving settings applies most of them immediately. How each group takes effect:
| Settings | How it applies |
|---|---|
| `controllerMethod` (all five channels) | Every channel is **rebuilt**: the running controllers are stopped and a fresh set is built from the new settings. A channel that owns a connection reconnects — Telegram re-runs its `getMe` handshake, NapCat opens a new WebSocket — so notifications sent during the swap are lost. The rebuild only happens when this section actually changed; saving a message template does not disturb the channels. |
| `controllerMessage`, `debug`, `bot_user_config.json`, `bot_node_config.json`, `webhook.endpoints` | Picked up as they are used; nothing is restarted. |
| `system.debugMode` | Applies to both behavior and log filtering. |
| `system.networkProxy` | Read on every request and every dial, so a new address reaches channels that are already running. Only the per-channel `networkUseProxy` opt-in is fixed when a channel is built, so toggling that still needs the rebuild above. |
| `system.listenAddr` / `listenPort`, `webhook.enabled` / `listenAddr` / `listenPort`, `dataPath`, `dbPath`, `komari.dashboardURL` | **Applied at startup only.** Saving them changes the file and the in-memory configuration but not the running listener, database or Komari client — restart to apply. |
## API ## API
Success responses follow the envelope `{"success": true, "message": "...", "data": {...}}`, with the payload nested under a single `data` key. Error responses use `{"success": false, "message": "..."}`. `message` may be omitted on success when there is nothing to report. Success responses follow the envelope `{"success": true, "message": "...", "data": {...}}`, with the payload nested under a single `data` key. Error responses use `{"success": false, "message": "..."}`. `message` may be omitted on success when there is nothing to report.
@@ -322,7 +334,7 @@ Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLog
| `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. | | `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. |
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. | | `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. |
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). | | `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). |
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. | | `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. Returns `data: {type, restartRequired}`, where `restartRequired` lists the keys the update changed that are only read at startup (see [What applies without a restart](#what-applies-without-a-restart)) — the write succeeds regardless, this only says which edits are not live yet. Always an array, empty when everything took effect. |
| `/api/webhook/add` | POST | admin | Add an incoming webhook endpoint. Body `{name, enabled?, token?, notifyPipes?}` — only the fields given are stored, the rest start at their defaults. `409` when the name is already configured. | | `/api/webhook/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/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/delete` | POST | admin | Remove an endpoint. Body `{name}`. `404` for an unknown name. |
@@ -375,7 +387,7 @@ Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`
## Bots ## Bots
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 (WebSocket event stream + HTTP actions); Telegram uses `go-telegram/bot` long polling. QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 over WebSocket (event stream + actions); Telegram uses `go-telegram/bot` long polling.
### Permission model ### Permission model
+10
View File
@@ -1,3 +1,13 @@
## Ver.0.2.1.6-d06d0bc.pre-release
### Features
- Most of settings now support hot reload when changed
- [Network proxy]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
- [Controllers]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/2a5a46a9846c5afe8ac3c66dc42164ddea25c038")
- [Only log node count when it changed]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/aa7e7f87e17f17ff2f64aa1823a00a291628d4eb")
### Bug Fixes
- [Resolve the proxy address per request]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
## Ver.0.2.0.5-661cf08.pre-release ## Ver.0.2.0.5-661cf08.pre-release
### Features ### Features
- [Enhance security of websocket log system]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9") - [Enhance security of websocket log system]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9")
+33 -10
View File
@@ -5,13 +5,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"time"
) )
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the // LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the // global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
// other config files this one is auto-generated and optional: a missing or // other config files this one is auto-generated and optional: a missing or
// empty file yields an empty map, so every node falls back to its default // empty file yields an empty map, so every node falls back to its defaults —
// enableStatusNotify value (true). // enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
func LoadBotNodeConfig(configPath string) error { func LoadBotNodeConfig(configPath string) error {
cfg := BotNodeMembers{} cfg := BotNodeMembers{}
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -44,6 +45,27 @@ func NodeStatusNotifyEnabled(uuid string) bool {
return *opts.EnableStatusNotify return *opts.EnableStatusNotify
} }
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
// hand-written value cannot overflow time.Duration.
const maxFlapTolerantSeconds = 86400
// NodeFlapTolerant returns how long an Offline notification for uuid should be
// held in order to absorb a quick reconnect, per bot_node_config.json's
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
// the value is zero or negative, or the config has not been loaded — in every
// case the caller falls back to notifying immediately.
func NodeFlapTolerant(uuid string) time.Duration {
opts, ok := BotNodes()[uuid]
if !ok || opts.FlapTolerant <= 0 {
return 0
}
seconds := opts.FlapTolerant
if seconds > maxFlapTolerantSeconds {
seconds = maxFlapTolerantSeconds
}
return time.Duration(seconds * float64(time.Second))
}
// LoadGlobalConfig reads and parses the configuration file, applies defaults, // LoadGlobalConfig reads and parses the configuration file, applies defaults,
// and stores it as a global singleton. // and stores it as a global singleton.
func LoadGlobalConfig(configPath string) (*Config, error) { func LoadGlobalConfig(configPath string) (*Config, error) {
@@ -65,15 +87,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
cfg.System.ListenPort = "8080" cfg.System.ListenPort = "8080"
} }
// Apply defaults for QQ controller. // Apply defaults for QQ_OneBot controller.
if cfg.ControllerMethod.QQ.ListenMethod == "" { if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
cfg.ControllerMethod.QQ.ListenMethod = "global" cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
} }
if cfg.ControllerMethod.QQ.NapcatAddr == "" { if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1" cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
} }
if cfg.ControllerMethod.QQ.NapcatPort == "" { if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
cfg.ControllerMethod.QQ.NapcatPort = "3000" cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
} }
// Apply defaults for Telegram controller. // Apply defaults for Telegram controller.
@@ -168,7 +190,8 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
// which is a plain registry of the nodes Komari reports (read back at startup // which is a plain registry of the nodes Komari reports (read back at startup
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID // by LoadBotNodeConfig). Entries already present are always preserved: a UUID
// Komari no longer reports on a given fetch is kept rather than deleted, and // Komari no longer reports on a given fetch is kept rather than deleted, and
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for // any per-node option a user set by hand — enableStatusNotify or flap_tolerant
// — is left untouched. UUIDs seen for
// the first time are added as an empty object, i.e. no parameter is written, so // the first time are added as an empty object, i.e. no parameter is written, so
// they inherit the enableStatusNotify default (true). The resulting JSON has // they inherit the enableStatusNotify default (true). The resulting JSON has
// its object keys emitted in sorted order by encoding/json, keeping the file // its object keys emitted in sorted order by encoding/json, keeping the file
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"encoding/json"
"testing"
"time"
"nukumizu-backend/global"
)
// TestNodeFlapTolerant covers the flap-inhibition window accessor against the
// per-node options in bot_node_config.json, including the hand-written values
// the comment on BotNodeOptions.FlapTolerant promises to accept.
func TestNodeFlapTolerant(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"seconds": { "flap_tolerant": 30 },
"fractional": { "flap_tolerant": 2.5 },
"zero": { "flap_tolerant": 0 },
"negative": { "flap_tolerant": -5 },
"unset": { "enableStatusNotify": true },
"absurd": { "flap_tolerant": 1e9 },
"throughput": { "flap_tolerant": 8, "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
cases := []struct {
name string
uuid string
want time.Duration
}{
{"whole seconds", "seconds", 30 * time.Second},
{"fractional seconds parse", "fractional", 2500 * time.Millisecond},
{"zero disables", "zero", 0},
{"negative disables", "negative", 0},
{"absent field disables", "unset", 0},
{"unknown node disables", "not-in-the-file", 0},
{"absurd value is clamped", "absurd", maxFlapTolerantSeconds * time.Second},
{"independent of enableStatusNotify", "throughput", 8 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NodeFlapTolerant(tc.uuid); got != tc.want {
t.Errorf("NodeFlapTolerant(%q) = %v, want %v", tc.uuid, got, tc.want)
}
})
}
}
// TestNodeFlapTolerantWithoutConfig pins the nil-safe default: before any
// bot_node_config.json has been loaded — and for a file that is missing or
// empty — no node has a window, so notifications keep their previous
// notify-immediately behavior.
func TestNodeFlapTolerantWithoutConfig(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, ``)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
if got := NodeFlapTolerant("anything"); got != 0 {
t.Errorf("node flap tolerance from an empty file = %v, want 0", got)
}
}
// nodeEntry reads one node's raw object back from the settings file, the way
// GetSettings serves it to the console.
func nodeEntry(t *testing.T, uuid string) map[string]interface{} {
t.Helper()
data, err := GetSettings(SettingBotNodeConfig)
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
var all map[string]map[string]interface{}
if err := json.Unmarshal(data, &all); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return all[uuid]
}
// TestFlapTolerantSettingsRoundTrip drives the payload the console's node card
// actually posts — { "<uuid>": { "flap_tolerant": <seconds> } } — through the
// settings API, so the frontend and the accessor are checked against each other
// and not just against hand-written files.
func TestFlapTolerantSettingsRoundTrip(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": 30},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 30*time.Second {
t.Errorf("NodeFlapTolerant after save = %v, want 30s", got)
}
// The sibling option must survive: the card saves only the field it edits.
entry := nodeEntry(t, "node-a")
if entry["enableStatusNotify"] != false {
t.Errorf("enableStatusNotify = %v, want it preserved as false", entry["enableStatusNotify"])
}
if entry["flap_tolerant"] != float64(30) {
t.Errorf("flap_tolerant on disk = %v, want 30", entry["flap_tolerant"])
}
}
// TestFlapTolerantSettingsRemoval: clearing the console's field sends a JSON
// null, which must remove the key rather than leave an explicit 0 behind.
func TestFlapTolerantSettingsRemoval(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": true, "flap_tolerant": 30 }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": nil},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 0 {
t.Errorf("NodeFlapTolerant after removal = %v, want 0", got)
}
entry := nodeEntry(t, "node-a")
if _, present := entry["flap_tolerant"]; present {
t.Error("flap_tolerant is still in the file after being cleared")
}
if entry["enableStatusNotify"] != true {
t.Errorf("enableStatusNotify = %v, want it preserved as true", entry["enableStatusNotify"])
}
}
+81
View File
@@ -0,0 +1,81 @@
package config
import (
"fmt"
"sync"
"nukumizu-backend/postLog"
)
// reloadHooks are the callbacks run after the global configuration has been
// reloaded, i.e. after every settings update that touches config.json.
//
// They exist so that packages which already depend on config — the controller
// manager, the logger — can react to an update without config importing them,
// which would be an import cycle. Everything a hook needs is passed in.
var (
reloadHooksMu sync.Mutex
reloadHooks []func(*Config)
// reloadRunMu serializes hook execution. A hook mutates process-wide state
// — the logger's debug flag, the controller registry — so two overlapping
// settings updates must not run them at the same time, or both would decide
// to rebuild the controllers from their own view of what was applied.
reloadRunMu sync.Mutex
)
// OnReload registers a hook to run after every reload of the global
// configuration, receiving the configuration now in effect. Hooks run in
// registration order.
//
// Register once, at startup, before the first settings update can arrive: a
// hook registered later has already missed the updates that came before it, and
// the configuration it would have seen is not replayed.
//
// A hook runs on the goroutine serving /api/settings/set, so it must not block
// for long. It may be called concurrently by two overlapping updates.
func OnReload(hook func(*Config)) {
reloadHooksMu.Lock()
defer reloadHooksMu.Unlock()
reloadHooks = append(reloadHooks, hook)
}
// notifyReload runs every registered hook with cfg. A nil cfg is the signal
// that the update touched one of the other settings files, which have no
// hook-visible reload, and it is ignored.
//
// A panicking hook is logged and skipped rather than allowed to unwind through
// UpdateSettings: by the time hooks run the new configuration is already on
// disk and published in memory, so reporting the write as failed would be a
// lie, and the hooks registered after the broken one must still run.
func notifyReload(cfg *Config) {
if cfg == nil {
return
}
// Copy under the registry lock, then release it before running anything: a
// hook is free to register another hook without deadlocking.
reloadHooksMu.Lock()
hooks := make([]func(*Config), len(reloadHooks))
copy(hooks, reloadHooks)
reloadHooksMu.Unlock()
reloadRunMu.Lock()
defer reloadRunMu.Unlock()
for _, hook := range hooks {
runReloadHook(hook, cfg)
}
}
// runReloadHook runs one hook, isolating a panic to that hook. Recovering in a
// separate function rather than inline is deliberate: a deferred recover placed
// in the loop body would not run until notifyReload itself returned, which
// would abandon the remaining hooks.
func runReloadHook(hook func(*Config), cfg *Config) {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Configuration reload hook panicked: %v", r))
}
}()
hook(cfg)
}
+173
View File
@@ -0,0 +1,173 @@
package config
import (
"reflect"
"testing"
"nukumizu-backend/global"
)
// swapReloadHooks replaces the registered reload hooks for the duration of a
// test and restores the previous set afterwards, so the package-level registry
// cannot leak into another test.
func swapReloadHooks(t *testing.T, hooks ...func(*Config)) {
t.Helper()
reloadHooksMu.Lock()
original := reloadHooks
reloadHooks = hooks
reloadHooksMu.Unlock()
t.Cleanup(func() {
reloadHooksMu.Lock()
reloadHooks = original
reloadHooksMu.Unlock()
})
}
func TestReloadHookRunsOnlyForGlobalSettings(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{}`)
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{}`)
var seen []bool
swapReloadHooks(t, func(cfg *Config) {
seen = append(seen, cfg.System.DebugMode)
})
// A config.json update runs the hook, with the values just written.
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"debugMode": true},
}); err != nil {
t.Fatalf("UpdateSettings(global): %v", err)
}
if len(seen) != 1 {
t.Fatalf("hook ran %d times for a config.json update, want 1", len(seen))
}
if !seen[0] {
t.Error("hook received a configuration without the updated debugMode")
}
// The other two files reload a singleton that callers read at the point of
// use, so they have nothing to notify.
for _, settingsType := range []string{SettingBotUserConfig, SettingBotNodeConfig} {
if err := UpdateSettings(settingsType, map[string]interface{}{
"unused": map[string]interface{}{"event_reply": true},
}); err != nil {
t.Fatalf("UpdateSettings(%s): %v", settingsType, err)
}
}
if len(seen) != 1 {
t.Errorf("hook ran %d times after updates to the other settings files, want 1", len(seen))
}
}
func TestReloadHookIsolatesPanic(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
reached := false
swapReloadHooks(t,
func(*Config) { panic("hook under test") },
func(*Config) { reached = true },
)
// The configuration is already on disk and published by the time hooks run,
// so a broken hook must not turn a successful write into a failed request.
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"debugMode": true},
}); err != nil {
t.Fatalf("a panicking hook must not fail the settings write: %v", err)
}
if !reached {
t.Error("a panicking hook stopped the hooks registered after it")
}
// The write itself must still have landed.
cfg := Current()
if cfg == nil || !cfg.System.DebugMode {
t.Error("the settings write did not take effect")
}
}
// TestUnrelatedUpdateLeavesControllerMethodAlone guards the trigger for a
// controller rebuild. Whether to rebuild is decided by comparing the whole
// controllerMethod section with the one the running controllers were built
// from, so reloading the file has to reproduce that section byte for byte. A
// default applied inconsistently — a nil recipient slice turned into an empty
// one on the second load, say — would make every settings edit look like a
// controller change and tear down every channel on each save.
func TestUnrelatedUpdateLeavesControllerMethodAlone(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{
"controllerMethod": {
"qq(napcat)": { "enabled": false },
"email": { "enabled": false, "to": [] },
"webhook": { "enabled": false, "headers": {} }
}
}`)
first, err := LoadGlobalConfig(global.ConfigPath.Global)
if err != nil {
t.Fatalf("LoadGlobalConfig: %v", err)
}
before := first.ControllerMethod
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"controllerMessage": map[string]interface{}{"BOT_STARTED": "hello"},
}); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
after := Current().ControllerMethod
if !reflect.DeepEqual(before, after) {
t.Errorf("an unrelated update changed the controllerMethod section:\nbefore: %+v\nafter: %+v", before, after)
}
}
// TestReloadHookSeesEveryWriterPath pins the invariant that each writer of
// config.json notifies, not just /api/settings/set: the webhook endpoint
// helpers go through the same channel.
func TestReloadHookSeesEveryWriterPath(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{
"webhook": { "endpoints": {} }
}`)
var seen int
swapReloadHooks(t, func(*Config) { seen++ })
if err := AddWebhookEndpoint("example", map[string]interface{}{
"enabled": true,
"token": "secret",
"notifyPipes": []interface{}{"ntfy"},
}); err != nil {
t.Fatalf("AddWebhookEndpoint: %v", err)
}
if seen != 1 {
t.Errorf("hook ran %d times after adding an endpoint, want 1", seen)
}
if err := ModifyWebhookEndpoint("example", map[string]interface{}{
"enabled": false,
}); err != nil {
t.Fatalf("ModifyWebhookEndpoint: %v", err)
}
if seen != 2 {
t.Errorf("hook ran %d times after modifying an endpoint, want 2", seen)
}
if err := DeleteWebhookEndpoint("example"); err != nil {
t.Fatalf("DeleteWebhookEndpoint: %v", err)
}
if seen != 3 {
t.Errorf("hook ran %d times after deleting an endpoint, want 3", seen)
}
// A rejected write changes nothing, so it must not notify either.
if err := DeleteWebhookEndpoint("ghost"); err == nil {
t.Error("deleting an unknown endpoint should fail")
}
if seen != 3 {
t.Errorf("hook ran for a rejected write (%d notifications, want 3)", seen)
}
}
+125 -14
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strings"
"sync" "sync"
"nukumizu-backend/global" "nukumizu-backend/global"
@@ -23,6 +24,89 @@ const (
// accepted constants above. // accepted constants above.
var ErrUnsupportedSettingsType = errors.New("unsupported settings type") var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
// startupOnlySettings are the config.json keys that are read once before the
// program starts serving and never again: the listener addresses, the paths the
// databases are opened from, and the Komari dashboard its client is built
// against. Editing one writes the file and replaces the in-memory
// configuration, but the running program keeps the old value, so an update that
// touches one is reported back to the caller instead of being silently
// accepted.
//
// Keep this in step with main: these are exactly the settings main reads before
// the HTTP server comes up. Everything else — controllerMethod, networkProxy,
// the message templates, the debug switches — is picked up at runtime.
var startupOnlySettings = []string{
"system.listenAddr",
"system.listenPort",
"webhook.enabled",
"webhook.listenAddr",
"webhook.listenPort",
"komari.dashboardURL",
"dataPath",
"dbPath",
}
// IsRestartRequiredKeys lists the settings in patch that only take effect at
// startup, as dot-separated paths, in the order startupOnlySettings declares
// them. Only config.json carries such settings; an update to one of the other
// files always reports nothing.
//
// The write itself succeeds either way — this is advice for the user, not a
// rejection. The result is never nil, so a caller can put it straight into a
// JSON response and get [] rather than null.
func IsRestartRequiredKeys(settingsType string, patch map[string]interface{}) []string {
keys := []string{}
if settingsType != SettingGlobal {
return keys
}
patched := patchPaths(patch)
for _, watched := range startupOnlySettings {
for _, path := range patched {
if isPathsOverlap(path, watched) {
keys = append(keys, watched)
break
}
}
}
return keys
}
// patchPaths expands a nested settings patch into the dot-separated paths of its
// leaves. An object is descended into rather than reported, so a patch that only
// names sections still resolves to the keys it changes, and a JSON null is a
// leaf because it deletes the key it names.
func patchPaths(patch map[string]interface{}) []string {
paths := []string{}
var walk func(prefix string, node map[string]interface{})
walk = func(prefix string, node map[string]interface{}) {
for key, value := range node {
path := key
if prefix != "" {
path = prefix + "." + key
}
if nested, ok := value.(map[string]interface{}); ok && nested != nil {
walk(path, nested)
continue
}
paths = append(paths, path)
}
}
walk("", patch)
return paths
}
// isPathsOverlap reports whether a patched path and a watched setting can affect
// each other: they are the same key, the patch names something inside the
// watched setting, or the patch names a section the watched setting lives in.
// The last case matters because a patch may replace a whole section, which
// changes every key under it.
func isPathsOverlap(patched, watched string) bool {
return patched == watched ||
strings.HasPrefix(patched, watched+".") ||
strings.HasPrefix(watched, patched+".")
}
// settingsLock serializes read-modify-write access to the on-disk configuration // settingsLock serializes read-modify-write access to the on-disk configuration
// files so concurrent admin edits (UpdateSettings) and the node tracker's // files so concurrent admin edits (UpdateSettings) and the node tracker's
// background save (SaveBotNodeConfig) cannot lose each other's updates. // background save (SaveBotNodeConfig) cannot lose each other's updates.
@@ -82,19 +166,45 @@ 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 {
return runSettingsUpdate(func() (*Config, error) {
return updateSettingsLocked(settingsType, patch)
})
}
// runSettingsUpdate runs fn under settingsLock and then, once the lock is
// released, runs the reload hooks with whatever configuration fn reports (nil
// when the update did not touch config.json).
//
// The hooks deliberately run outside settingsLock. A hook rebuilds controllers,
// which can wait on a network call, while settingsLock is also held by the node
// tracker's background save (SaveBotNodeConfig); holding it across a hook would
// stall node registration behind an unrelated settings edit.
func runSettingsUpdate(fn func() (*Config, error)) error {
cfg, err := func() (*Config, error) {
settingsLock.Lock() settingsLock.Lock()
defer settingsLock.Unlock() defer settingsLock.Unlock()
return fn()
}()
if err != nil {
return err
}
return updateSettingsLocked(settingsType, patch) notifyReload(cfg)
return nil
} }
// updateSettingsLocked is UpdateSettings without the locking, for callers that // updateSettingsLocked is UpdateSettings without the locking, for callers that
// need to inspect the loaded configuration and write in one critical section // need to inspect the loaded configuration and write in one critical section
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock. // (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
func updateSettingsLocked(settingsType string, patch map[string]interface{}) error { //
// It returns the freshly loaded global configuration, or nil when the settings
// type is one of the other files. The caller is responsible for handing that
// value to notifyReload once settingsLock is released — which runSettingsUpdate
// does for every writer.
func updateSettingsLocked(settingsType string, patch map[string]interface{}) (*Config, error) {
path, err := settingsPath(settingsType) path, err := settingsPath(settingsType)
if err != nil { if err != nil {
return err return nil, err
} }
// Start from whatever is already on disk so nothing is dropped. A missing or // Start from whatever is already on disk so nothing is dropped. A missing or
@@ -104,23 +214,23 @@ func updateSettingsLocked(settingsType string, patch map[string]interface{}) err
if err == nil { if err == nil {
if len(bytes.TrimSpace(data)) > 0 { if len(bytes.TrimSpace(data)) > 0 {
if err := json.Unmarshal(data, &current); err != nil { if err := json.Unmarshal(data, &current); err != nil {
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err) return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
} }
} }
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err) return nil, fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
} }
deepMergeSettings(current, patch) deepMergeSettings(current, patch)
data, err = json.MarshalIndent(current, "", " ") data, err = json.MarshalIndent(current, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal %s settings: %w", settingsType, err) return nil, fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
} }
data = append(data, '\n') data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil { if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err) return nil, fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
} }
return reloadSettings(settingsType, path) return reloadSettings(settingsType, path)
@@ -151,18 +261,19 @@ func deepMergeSettings(dst, src map[string]interface{}) {
} }
// reloadSettings refreshes the in-memory singleton for the given settings type // reloadSettings refreshes the in-memory singleton for the given settings type
// so the running program observes the values just persisted to disk. // so the running program observes the values just persisted to disk. Only
func reloadSettings(settingsType, path string) error { // config.json has a hook-visible reload, so for SettingGlobal it returns the
// configuration now in effect and for the other types it returns nil.
func reloadSettings(settingsType, path string) (*Config, error) {
switch settingsType { switch settingsType {
case SettingGlobal: case SettingGlobal:
_, err := LoadGlobalConfig(path) return LoadGlobalConfig(path)
return err
case SettingBotUserConfig: case SettingBotUserConfig:
_, err := LoadBotUserConfig(path) _, err := LoadBotUserConfig(path)
return err return nil, err
case SettingBotNodeConfig: case SettingBotNodeConfig:
return LoadBotNodeConfig(path) return nil, LoadBotNodeConfig(path)
default: default:
return ErrUnsupportedSettingsType return nil, ErrUnsupportedSettingsType
} }
} }
+157
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -148,6 +149,162 @@ func TestUpdateSettingsReplacesArraysAndKeepsNumbers(t *testing.T) {
} }
} }
func TestRestartRequiredKeys(t *testing.T) {
cases := []struct {
name string
settingsType string
patch map[string]interface{}
want []string
}{
{
name: "a runtime switch needs no restart",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"debugMode": true}},
want: []string{},
},
{
name: "the listen port does",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": "9090"}},
want: []string{"system.listenPort"},
},
{
name: "only the startup key of a mixed patch is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090", "debugMode": true},
},
want: []string{"system.listenPort"},
},
{
name: "a top-level path is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{"dataPath": "/srv/data"},
want: []string{"dataPath"},
},
{
name: "results follow the declared order, not the patch order",
settingsType: SettingGlobal,
patch: map[string]interface{}{"dbPath": "/srv/db", "dataPath": "/srv/data"},
want: []string{"dataPath", "dbPath"},
},
{
name: "deleting a startup key with null is reported",
settingsType: SettingGlobal,
patch: map[string]interface{}{"system": map[string]interface{}{"listenPort": nil}},
want: []string{"system.listenPort"},
},
{
name: "replacing a whole section reports the startup keys inside it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"webhook": map[string]interface{}{"listenAddr": "127.0.0.1"}},
want: []string{"webhook.listenAddr"},
},
{
// Deleting the section resets the URL to its built-in default.
name: "deleting a section the startup key lives in reports it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"komari": nil},
want: []string{"komari.dashboardURL"},
},
{
name: "deleting a section reports every startup key inside it",
settingsType: SettingGlobal,
patch: map[string]interface{}{"webhook": nil},
want: []string{"webhook.enabled", "webhook.listenAddr", "webhook.listenPort"},
},
{
// An empty object merges nothing, so it changes no key and needs no
// restart — surprising enough to pin.
name: "an empty object changes nothing",
settingsType: SettingGlobal,
patch: map[string]interface{}{"komari": map[string]interface{}{}},
want: []string{},
},
{
// webhook.endpoints must not be mistaken for webhook.enabled.
name: "a sibling subtree is not mistaken for the startup key",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"webhook": map[string]interface{}{
"endpoints": map[string]interface{}{"example": map[string]interface{}{"enabled": true}},
},
},
want: []string{},
},
{
// The Komari credentials are re-read on the next login, so only the
// dashboard URL is startup-only.
name: "komari credentials are not startup-only",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"komari": map[string]interface{}{
"account": map[string]interface{}{"username": "admin", "password": "x"},
},
},
want: []string{},
},
{
name: "controller settings are not startup-only",
settingsType: SettingGlobal,
patch: map[string]interface{}{
"controllerMethod": map[string]interface{}{
"telegram": map[string]interface{}{"enabled": true, "botToken": "t"},
},
},
want: []string{},
},
{
name: "an empty patch reports nothing",
settingsType: SettingGlobal,
patch: map[string]interface{}{},
want: []string{},
},
{
// Only config.json has settings that are read once at startup.
name: "the other settings files never need a restart",
settingsType: SettingBotUserConfig,
patch: map[string]interface{}{"dataPath": "/srv/data"},
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := IsRestartRequiredKeys(tc.settingsType, tc.patch)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("RestartRequiredKeys() = %v, want %v", got, tc.want)
}
if got == nil {
t.Error("the result must never be nil, so it serializes as [] rather than null")
}
})
}
}
// TestRestartRequiredKeysIsAdvisory pins that reporting a startup-only key does
// not stop the write: the caller is told, the file is still updated.
func TestRestartRequiredKeysIsAdvisory(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"listenPort":"8080"}}`)
keys := IsRestartRequiredKeys(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090"},
})
if len(keys) != 1 {
t.Fatalf("expected the listen port to be reported, got %v", keys)
}
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
"system": map[string]interface{}{"listenPort": "9090"},
}); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := Current().System.ListenPort; got != "9090" {
t.Errorf("the update was not applied: listenPort = %q", got)
}
}
func TestSettingsTypeValidation(t *testing.T) { func TestSettingsTypeValidation(t *testing.T) {
for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} { for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} {
if !IsValidSettingsType(valid) { if !IsValidSettingsType(valid) {
+25 -13
View File
@@ -15,12 +15,13 @@ type SystemConfig struct {
// DebugConfig holds debug-level configuration. // DebugConfig holds debug-level configuration.
type DebugConfig struct { type DebugConfig struct {
ShowNapcatMsg bool `json:"showNapcatMsg"` ShowOneBotMsg bool `json:"showOneBotMsg"`
ShowNapcatAction bool `json:"showNapcatAction"` ShowOneBotAction bool `json:"showOneBotAction"`
ShowTelegramMsg bool `json:"showTelegramMsg"` ShowTelegramMsg bool `json:"showTelegramMsg"`
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"` ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"` ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"` OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
} }
// KomariAccount holds Komari login credentials. // KomariAccount holds Komari login credentials.
@@ -35,14 +36,14 @@ type KomariConfig struct {
Account KomariAccount `json:"account"` Account KomariAccount `json:"account"`
} }
// QQConfig holds QQ (Napcat) Bot controller configuration. // QQConfig holds QQ (OneBot) Bot controller configuration.
type QQConfig struct { type OneBotConfig struct {
Markdown bool `json:"markdown"` Markdown bool `json:"markdown"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NetworkUseProxy bool `json:"networkUseProxy"` NetworkUseProxy bool `json:"networkUseProxy"`
NapcatAddr string `json:"napcatAddr"` OneBotAddr string `json:"oneBotAddr"`
NapcatPort string `json:"napcatPort"` OneBotPort string `json:"oneBotPort"`
NapcatToken string `json:"napcatToken"` OneBotToken string `json:"oneBotToken"`
BotQQID int64 `json:"botQQID"` BotQQID int64 `json:"botQQID"`
ListenMethod string `json:"listenMethod"` ListenMethod string `json:"listenMethod"`
} }
@@ -96,7 +97,7 @@ type WebhookConfig struct {
// ControllerMethodConfig holds all controller method configurations. // ControllerMethodConfig holds all controller method configurations.
type ControllerMethodConfig struct { type ControllerMethodConfig struct {
QQ QQConfig `json:"qq(napcat)"` QQ_OneBot OneBotConfig `json:"qq_onebot"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"` Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"` Ntfy NtfyConfig `json:"ntfy"`
@@ -119,7 +120,7 @@ type WebhookEndpointConfig struct {
Token string `json:"token"` Token string `json:"token"`
// NotifyPipes lists the notification channels the alert is delivered to, by // NotifyPipes lists the notification channels the alert is delivered to, by
// controller name (e.g. "qq(napcat)", "telegram", "email", "ntfy", // controller name (e.g. "qq(onebot)", "telegram", "email", "ntfy",
// "webhook"). // "webhook").
NotifyPipes []string `json:"notifyPipes"` NotifyPipes []string `json:"notifyPipes"`
} }
@@ -216,7 +217,7 @@ func (m BotUserMembers) IDs() []string {
return ids return ids
} }
// BotUser_QQConfig holds the admins and trusted groups for the QQ (Napcat) bot. // BotUser_QQConfig holds the admins and trusted groups for the QQ (OneBot) bot.
type BotUser_QQConfig struct { type BotUser_QQConfig struct {
Admins BotUserMembers `json:"admins"` Admins BotUserMembers `json:"admins"`
TrustedGroups BotUserMembers `json:"trustedGroups"` TrustedGroups BotUserMembers `json:"trustedGroups"`
@@ -232,7 +233,7 @@ type BotUser_TelegramConfig struct {
// and trusted groups (chat targets) for each bot channel separately from the // and trusted groups (chat targets) for each bot channel separately from the
// main config.json. // main config.json.
type BotUserConfig struct { type BotUserConfig struct {
QQ BotUser_QQConfig `json:"qq(napcat)"` QQ BotUser_QQConfig `json:"qq_onebot"`
Telegram BotUser_TelegramConfig `json:"telegram"` Telegram BotUser_TelegramConfig `json:"telegram"`
} }
@@ -257,13 +258,24 @@ type BotNodeOptions struct {
// bool) lets an absent field be told apart from an explicit false, and // bool) lets an absent field be told apart from an explicit false, and
// omitempty keeps untouched nodes stored as {}. // omitempty keeps untouched nodes stored as {}.
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"` EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
// FlapTolerant is the number of seconds a node's Offline notification is
// held in the hope the node returns. A node that comes back online within
// this window produces no notification at all; one still offline when the
// window elapses reports Offline as usual. Zero (or an absent field)
// disables inhibition, which keeps the notify-immediately behavior. It is
// a float64 rather than an int so a hand-written fractional value such as
// 2.5 parses instead of failing the whole config load at startup, and it
// is not a pointer because 0 already means "off", unlike
// EnableStatusNotify whose default is true.
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
} }
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node // BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
// options. // options.
type BotNodeMembers map[string]BotNodeOptions type BotNodeMembers map[string]BotNodeOptions
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig. // NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
// The map is rebuilt rather than mutated on every load, so the pointer can be // The map is rebuilt rather than mutated on every load, so the pointer can be
// swapped atomically; read it through BotNodes. // swapped atomically; read it through BotNodes.
var botNodeConfig atomic.Pointer[BotNodeMembers] var botNodeConfig atomic.Pointer[BotNodeMembers]
+9 -13
View File
@@ -60,13 +60,12 @@ func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
return err return err
} }
settingsLock.Lock() return runSettingsUpdate(func() (*Config, error) {
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); exists { if _, exists := webhookEndpoint(name); exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name) return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
} }
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch)) return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
})
} }
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the // ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
@@ -84,27 +83,24 @@ func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid) return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
} }
settingsLock.Lock() return runSettingsUpdate(func() (*Config, error) {
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); !exists { if _, exists := webhookEndpoint(name); !exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name) return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
} }
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch)) return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
})
} }
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under // DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
// name. The endpoint stops accepting requests as soon as the configuration is // name. The endpoint stops accepting requests as soon as the configuration is
// reloaded. // reloaded.
func DeleteWebhookEndpoint(name string) error { func DeleteWebhookEndpoint(name string) error {
settingsLock.Lock() return runSettingsUpdate(func() (*Config, error) {
defer settingsLock.Unlock()
if _, exists := webhookEndpoint(name); !exists { if _, exists := webhookEndpoint(name); !exists {
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name) return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
} }
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name)) return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
})
} }
// webhookEndpoint returns the named endpoint held by the loaded configuration. // webhookEndpoint returns the named endpoint held by the loaded configuration.
+1 -1
View File
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
## Features ## Features
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists). - **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes. - **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load. The pencil on a card opens a per-node options dialog holding the *status notify* switch and the *flap inhibition* window, persisted to `bot_node_config.json`. Searchable, auto-refreshes.
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`. - **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section. - **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export. - **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
-3
View File
@@ -1473,7 +1473,6 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1596,7 +1595,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -1671,7 +1669,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.42", "@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42", "@vue/compiler-sfc": "3.5.42",
+4
View File
@@ -13,6 +13,10 @@ export const serverApi = {
// /api/settings/get?type=… / /api/settings/set?type=… // /api/settings/get?type=… / /api/settings/set?type=…
// get → { success, message, data: { config } }. // get → { success, message, data: { config } }.
// set → { success, message, data: { type, restartRequired } }, where
// restartRequired lists the keys the update changed that are only read at
// startup, so the caller can say which edits are not live yet. It is
// always an array, empty when the whole update took effect.
// `type` is one of global | bot_user_config | bot_node_config. // `type` is one of global | bot_user_config | bot_node_config.
// For set, pass a partial object; a JSON null value removes that key. // For set, pass a partial object; a JSON null value removes that key.
export const settingsApi = { export const settingsApi = {
+8 -1
View File
@@ -133,8 +133,15 @@ async function save() {
const patch = wrapRoot(props.section, nest(obj)); const patch = wrapRoot(props.section, nest(obj));
saving.value = true; saving.value = true;
try { try {
await settingsApi.set('global', patch); const res = await settingsApi.set('global', patch);
// The backend reports the keys it wrote that are only read at startup.
// A plain "saved" would suggest those are live too.
const pending = (res && res.data && res.data.restartRequired) || [];
if (pending.length) {
toast.warn(`${props.section.title} saved — restart to apply: ${pending.join(', ')}`, 7000);
} else {
toast.success(`${props.section.title} saved`); toast.success(`${props.section.title} saved`);
}
emit('saved'); emit('saved');
} catch (e) { } catch (e) {
toast.error('Failed to save: ' + e.message); toast.error('Failed to save: ' + e.message);
+156 -20
View File
@@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js'; import { toast } from '../utils/toast.js';
import { formatBytes, shortUUID } from '../utils/fmt.js'; import { formatBytes, shortUUID } from '../utils/fmt.js';
import Toggle from '../components/Toggle.vue'; import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue';
function percent(m) { function percent(m) {
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0; return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
@@ -13,9 +14,20 @@ const query = ref('');
const loading = ref(true); const loading = ref(true);
const failed = ref(false); const failed = ref(false);
const entries = ref([]); const entries = ref([]);
const saving = reactive(new Set());
let timer = null; let timer = null;
// The per-node options dialog. Its form is a copy of the node's settings taken
// when the dialog opens, so the 15s card refresh behind it cannot disturb an
// open editor, and closing without saving leaves the node untouched.
const editing = reactive({
open: false,
uuid: '',
name: '',
notify: true,
flap: '',
saving: false
});
const filtered = computed(() => { const filtered = computed(() => {
const q = query.value.trim().toLowerCase(); const q = query.value.trim().toLowerCase();
if (!q) return entries.value; if (!q) return entries.value;
@@ -57,7 +69,8 @@ async function load() {
online: !!s.online, online: !!s.online,
report: s.report || null, report: s.report || null,
info: infoEntry.info || null, info: infoEntry.info || null,
notify: conf.enableStatusNotify !== false notify: conf.enableStatusNotify !== false,
flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0
}); });
} }
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1)); list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
@@ -71,21 +84,42 @@ async function load() {
} }
} }
async function toggleNotify(entry) { function openOptions(entry) {
const next = !entry.notify; editing.uuid = entry.uuid;
const prev = entry.notify; editing.name = entry.name;
entry.notify = next; editing.notify = entry.notify;
saving.add(entry.uuid); editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : '';
editing.open = true;
}
// saveOptions writes both options in one patch. A flap window that is not a
// positive number disables inhibition, which is sent as a JSON null so the key
// is removed rather than stored as an explicit 0 — the file then keeps matching
// its documented "absent means off" default.
async function saveOptions() {
const typed = Number(editing.flap);
const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0;
const uuid = editing.uuid;
editing.saving = true;
try { try {
const patch = {}; const patch = {};
patch[entry.uuid] = { enableStatusNotify: next }; patch[uuid] = {
enableStatusNotify: editing.notify,
flap_tolerant: seconds > 0 ? seconds : null
};
await settingsApi.set('bot_node_config', patch); await settingsApi.set('bot_node_config', patch);
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`); const entry = entries.value.find((e) => e.uuid === uuid);
if (entry) {
entry.notify = editing.notify;
entry.flapTolerant = seconds;
}
toast.success(`${editing.name}: options saved`);
editing.open = false;
} catch (e) { } catch (e) {
entry.notify = prev;
toast.error('Save failed: ' + e.message); toast.error('Save failed: ' + e.message);
} finally { } finally {
saving.delete(entry.uuid); editing.saving = false;
} }
} }
@@ -111,7 +145,6 @@ onBeforeUnmount(() => {
<div> <div>
<p class="eyebrow">Overview</p> <p class="eyebrow">Overview</p>
<h1>Nodes</h1> <h1>Nodes</h1>
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load"> <button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
@@ -207,17 +240,60 @@ onBeforeUnmount(() => {
<span>·</span> <span>·</span>
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span> <span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
</div> </div>
<div class="notify"> <div class="opts">
<Toggle <span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
:model-value="e.notify" <i class="fas fa-bell-slash" /> Notify off
label="Status notify" </span>
:disabled="saving.has(e.uuid)" <span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
@update:model-value="toggleNotify(e)" <i class="fas fa-shield-halved" /> Flap {{ e.flapTolerant }}s
/> </span>
<button class="icon-btn" title="Node options" @click="openOptions(e)">
<i class="fas fa-pen" />
</button>
</div> </div>
</footer> </footer>
</article> </article>
</div> </div>
<Modal :open="editing.open" title="Node options" @close="editing.open = false">
<div class="node-head">
<p class="node-name">{{ editing.name }}</p>
<p class="node-uuid mono">{{ editing.uuid }}</p>
</div>
<div class="field">
<label>Status notify</label>
<Toggle v-model="editing.notify" label="Report online / offline changes" />
<p class="help">With this off, the node's status changes are not delivered to any pipes.</p>
</div>
<div class="field">
<label>Flap inhibition</label>
<div class="flap-row">
<input
v-model="editing.flap"
class="input flap-input"
type="number"
min="0"
max="86400"
step="1"
inputmode="numeric"
placeholder="0"
@keydown.enter="saveOptions"
/>
<span class="flap-unit">seconds</span>
</div>
<p class="help">How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.</p>
</div>
<template #foot>
<button class="btn btn-ghost" @click="editing.open = false">Cancel</button>
<button class="btn btn-primary" :disabled="editing.saving" @click="saveOptions">
<span v-if="editing.saving" class="spinner" style="width:12px;height:12px" />
<i v-else class="fas fa-check" /> Save
</button>
</template>
</Modal>
</section> </section>
</template> </template>
@@ -298,7 +374,67 @@ onBeforeUnmount(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.notify { display: flex; align-items: center; } .opts {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
/* Stay right-aligned even when the spec block is absent. */
margin-left: auto;
}
.opts .chip { white-space: nowrap; }
/* --- Node options dialog ------------------------------------------------ */
.node-head {
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.node-name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.node-uuid {
font-size: 11.5px;
color: var(--text-3);
margin-top: 3px;
overflow-wrap: anywhere;
}
/* The modal body has no grid to space stacked fields. */
.field + .field { margin-top: 20px; }
.flap-row {
display: flex;
align-items: center;
gap: 9px;
}
.flap-input {
width: 110px;
text-align: center;
font-variant-numeric: tabular-nums;
/* Match the plain hand-rolled field look: the rest of the console renders
numbers as ordinary inputs, so the spinner controls would stand out. */
-moz-appearance: textfield;
appearance: textfield;
}
.flap-input::-webkit-outer-spin-button,
.flap-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.flap-unit {
font-size: 12.5px;
color: var(--text-3);
}
.refresh-hint { .refresh-hint {
font-size: 12px; font-size: 12px;
+17 -17
View File
@@ -11,7 +11,7 @@ const sections = [
{ {
id: 'system', id: 'system',
title: 'System', title: 'System',
hint: 'HTTP listener and global runtime switches.', hint: 'HTTP listener and global runtime switches. A changed listen address or port applies on restart.',
root: ['system'], root: ['system'],
fields: [ fields: [
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' }, { key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
@@ -23,21 +23,22 @@ const sections = [
{ {
id: 'debug', id: 'debug',
title: 'Debug logging', title: 'Debug logging',
hint: 'Per-module verbosity for the bot pipes.', hint: '',
root: ['debug'], root: ['debug'],
fields: [ fields: [
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' }, { key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' },
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' }, { key: 'showOneBotAction', type: 'bool', label: 'OneBot actions' },
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' }, { key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' }, { key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' }, { key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' } { key: 'showFlapTolerantEcho', type: 'bool', label: 'Flap inhibition echo' },
{ key: 'onebotIgnoreSelfMsg', type: 'bool', label: 'Ignore OneBot self messages' }
] ]
}, },
{ {
id: 'komari', id: 'komari',
title: 'Komari dashboard', title: 'Komari dashboard',
hint: 'Connection the monitor reads node data from. Takes effect on restart.', hint: 'Connection the monitor reads node data from. The URL applies on restart; the account is re-read on the next login.',
root: ['komari'], root: ['komari'],
fields: [ fields: [
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' }, { key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
@@ -60,16 +61,16 @@ const sections = [
] ]
}, },
{ {
id: 'qq', id: 'qq_onebot',
title: 'QQ controller (NapCat)', title: 'QQ controller (OneBot)',
root: ['controllerMethod', 'qq(napcat)'], root: ['controllerMethod', 'qq_onebot'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' }, { key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
{ key: 'napcatPort', type: 'text', label: 'NapCat port' }, { key: 'oneBotPort', type: 'text', label: 'OneBot port' },
{ key: 'napcatToken', type: 'password', label: 'NapCat token' }, { key: 'oneBotToken', type: 'password', label: 'OneBot token' },
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' }, { key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] } { key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
] ]
@@ -80,7 +81,7 @@ const sections = [
root: ['controllerMethod', 'telegram'], root: ['controllerMethod', 'telegram'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'botToken', type: 'password', label: 'Bot token' }, { key: 'botToken', type: 'password', label: 'Bot token' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] } { key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
@@ -92,7 +93,7 @@ const sections = [
root: ['controllerMethod', 'email'], root: ['controllerMethod', 'email'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'smtpHost', type: 'text', label: 'SMTP host' }, { key: 'smtpHost', type: 'text', label: 'SMTP host' },
{ key: 'smtpPort', type: 'number', label: 'SMTP port' }, { key: 'smtpPort', type: 'number', label: 'SMTP port' },
@@ -109,7 +110,7 @@ const sections = [
root: ['controllerMethod', 'ntfy'], root: ['controllerMethod', 'ntfy'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'server', type: 'text', label: 'Server' }, { key: 'server', type: 'text', label: 'Server' },
{ key: 'topic', type: 'text', label: 'Topic' }, { key: 'topic', type: 'text', label: 'Topic' },
@@ -124,7 +125,7 @@ const sections = [
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: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'url', type: 'text', label: 'URL' }, { key: 'url', type: 'text', label: 'URL' },
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, { key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
@@ -170,7 +171,6 @@ onMounted(load);
<div> <div>
<p class="eyebrow">Configuration</p> <p class="eyebrow">Configuration</p>
<h1>System settings</h1> <h1>System settings</h1>
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button> <button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
+1 -1
View File
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
const CHANNELS = [ const CHANNELS = [
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' }, { key: 'qq_onebot', label: 'QQ (OneBot)', hint: 'Members are identified by QQ number / group number' },
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' } { key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
]; ];
+1 -1
View File
@@ -10,7 +10,7 @@ import Modal from '../components/Modal.vue';
// controller names in config.json (controllerMethod.*), which is exactly what // controller names in config.json (controllerMethod.*), which is exactly what
// the backend matches notifyPipes against. // the backend matches notifyPipes against.
const CHANNELS = [ const CHANNELS = [
{ key: 'qq(napcat)', label: 'QQ' }, { key: 'qq_onebot', label: 'QQ_OneBot' },
{ key: 'telegram', label: 'Telegram' }, { key: 'telegram', label: 'Telegram' },
{ key: 'email', label: 'Email' }, { key: 'email', label: 'Email' },
{ key: 'ntfy', label: 'ntfy' }, { key: 'ntfy', label: 'ntfy' },
+2 -2
View File
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
var SoftwareInfo = SoftwareInfoStr{ var SoftwareInfo = SoftwareInfoStr{
Name: "Nukumizu", Name: "Nukumizu",
Version: "0.2.0", Version: "0.2.2",
Developer: "Madobi Nanami", Developer: "Madobi Nanami",
BuildVer: 5, BuildVer: 7,
CommitHash: "unknown", CommitHash: "unknown",
Description: "Remote server monitoring and command execution subsystem for Komari", Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "pre-release", BuildType: "pre-release",
+7
View File
@@ -47,6 +47,10 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
// {"system": {"debugMode": true}} // {"system": {"debugMode": true}}
// //
// Multiple entries may be given at once; only the provided keys are changed. // Multiple entries may be given at once; only the provided keys are changed.
//
// The response carries data.restartRequired: the keys the update changed that
// are only read at startup, so the caller can say which edits are not live yet.
// It is empty for an update that took effect in full.
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) { func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "admin") { if !utils.Auth(w, r, "POST", "admin") {
return return
@@ -80,7 +84,10 @@ func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// The write landed either way. This only tells the caller which of the keys
// it changed will not be live until the program is restarted.
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{ utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
"type": settingsType, "type": settingsType,
"restartRequired": config.IsRestartRequiredKeys(settingsType, patch),
}) })
} }
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"nukumizu-backend/config"
"nukumizu-backend/global"
)
// tempConfigFile points one of the settings paths at a throwaway file, so a
// test can drive the settings API without touching the config files in the
// working directory.
func tempConfigFile(t *testing.T, field *string, content string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
original := *field
*field = path
t.Cleanup(func() { *field = original })
}
// settingsSetRequest builds an authenticated POST for the settings endpoint.
func settingsSetRequest(t *testing.T, settingsType, body string) *http.Request {
t.Helper()
req := httptest.NewRequest(
http.MethodPost,
"/api/settings/set?type="+settingsType,
strings.NewReader(body),
)
req.Header.Set("X-Token", "test-admin-token")
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
return req
}
// settingsSetResponse is the envelope /api/settings/set answers with.
type settingsSetResponse struct {
Success bool `json:"success"`
Data struct {
Type string `json:"type"`
RestartRequired []string `json:"restartRequired"`
} `json:"data"`
}
func decodeSettingsSetResponse(t *testing.T, w *httptest.ResponseRecorder) settingsSetResponse {
t.Helper()
var body settingsSetResponse
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
}
return body
}
// TestSettingsSetReportsStartupOnlyKeys covers the field the console reads to
// tell the user which of their edits are not live yet.
func TestSettingsSetReportsStartupOnlyKeys(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false,"listenPort":"8080"}}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "global",
`{"system":{"listenPort":"9090","debugMode":true}}`))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
body := decodeSettingsSetResponse(t, w)
if !body.Success {
t.Fatalf("not a success envelope: %s", w.Body.String())
}
if body.Data.Type != "global" {
t.Errorf("data.type = %q, want global", body.Data.Type)
}
if len(body.Data.RestartRequired) != 1 || body.Data.RestartRequired[0] != "system.listenPort" {
t.Errorf("restartRequired = %v, want [system.listenPort]", body.Data.RestartRequired)
}
// Being reported as startup-only must not stop the write.
data, err := config.GetSettings(config.SettingGlobal)
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
for _, want := range []string{`"listenPort": "9090"`, `"debugMode": true`} {
if !strings.Contains(string(data), want) {
t.Errorf("the patch was not applied, missing %s:\n%s", want, data)
}
}
}
// TestSettingsSetRestartRequiredIsAlwaysAnArray pins the shape a client
// iterates over: an update with nothing to report must answer [] and not null.
func TestSettingsSetRestartRequiredIsAlwaysAnArray(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "global", `{"system":{"debugMode":true}}`))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"restartRequired":[]`) {
t.Errorf("restartRequired should serialize as an empty array: %s", w.Body.String())
}
}
// TestSettingsSetRejectsUnknownType keeps the failure path intact now that the
// success path computes an extra field.
func TestSettingsSetRejectsUnknownType(t *testing.T) {
setupAdminToken()
tempConfigFile(t, &global.ConfigPath.Global, `{}`)
w := httptest.NewRecorder()
SettingsSetHandler(w, settingsSetRequest(t, "nonsense", `{}`))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
+245 -9
View File
@@ -3,8 +3,11 @@ package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"reflect"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time"
"nukumizu-backend/config" "nukumizu-backend/config"
"nukumizu-backend/internal/node" "nukumizu-backend/internal/node"
@@ -25,7 +28,7 @@ type Command struct {
// Message represents a message to be sent by a controller. // Message represents a message to be sent by a controller.
type Message struct { type Message struct {
Source string // The source pipe (e.g., "telegram", "qq", "napcat") Source string // The source pipe (e.g., "telegram", "qq", "onebot")
Content string // The message content Content string // The message content
ChatID int64 // Chat/group ID where the message should be sent ChatID int64 // Chat/group ID where the message should be sent
Type string // Message type (see MessageType*), used for per-member opt-outs Type string // Message type (see MessageType*), used for per-member opt-outs
@@ -98,17 +101,45 @@ type Controller interface {
// BotController is implemented by controllers that act as chat bots and can // BotController is implemented by controllers that act as chat bots and can
// deliver arbitrary messages, such as the bot initialization message on startup. // deliver arbitrary messages, such as the bot initialization message on startup.
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only // Only bot-type pipes (QQ_OneBot, Telegram) implement it; notification-only
// pipes (email, ntfy, webhook) do not. // pipes (email, ntfy, webhook) do not.
type BotController interface { type BotController interface {
Controller Controller
SendMessage(message Message) error SendMessage(message Message) error
} }
// flapPending is an Offline notification being held back for a node's
// flap_tolerant window, in the hope that the node returns before the deadline.
// The held change is kept by value so a later mutation of the caller's copy
// cannot reach it.
type flapPending struct {
timer *time.Timer
change node.StatusChange
}
// Manager manages all controller instances and routes events. // Manager manages all controller instances and routes events.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
controllers map[string]Controller controllers map[string]Controller
// builtFrom records the controllerMethod section the registered controllers
// were built from, so a settings update that concerns them can be told apart
// from one that does not. It is read on the settings-update goroutine and
// written when the set is replaced.
builtFrom atomic.Pointer[config.ControllerMethodConfig]
// flapMu guards flapPending, the Offline notifications currently held for
// flap inhibition, keyed by node UUID. It is a separate lock from mu and is
// never held while mu is taken: a held notification is dispatched by a timer
// goroutine, which must not block behind a routing swap, and dispatching
// (network I/O per controller) must not happen while holding the flap lock.
flapMu sync.Mutex
flapPending map[string]*flapPending
// flapToleranceFor overrides the per-node flap window. It is nil in
// production, where the value comes from bot_node_config.json through
// config.NodeFlapTolerant; tests set it to keep their timings hermetic.
flapToleranceFor func(uuid string) time.Duration
} }
var globalManager *Manager var globalManager *Manager
@@ -117,6 +148,7 @@ var globalManager *Manager
func InitManager() { func InitManager() {
globalManager = &Manager{ globalManager = &Manager{
controllers: make(map[string]Controller), controllers: make(map[string]Controller),
flapPending: make(map[string]*flapPending),
} }
postLog.Info("Controller manager initialized") postLog.Info("Controller manager initialized")
} }
@@ -126,16 +158,74 @@ func GetManager() *Manager {
return globalManager return globalManager
} }
// Register adds a controller to the manager. // NeedsRebuild reports whether next differs from the controllerMethod section
func (m *Manager) Register(c Controller) { // the registered controllers were built from. A manager with no controllers yet
// always reports true, so the first call installs the initial set.
//
// Controllers are rebuilt wholesale rather than reconfigured in place: each one
// reads its settings into fields at construction, and two of them own
// connections that cannot be re-pointed (the OneBot WebSocket listener is
// stopped through a sync.Once, the Telegram polling context is created with the
// controller). Replacing the set keeps every channel on the same footing.
func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool {
built := m.builtFrom.Load()
if built == nil {
return true
}
// The section carries a header map and a recipient slice, so it is not
// comparable with ==.
return !reflect.DeepEqual(*built, next)
}
// ReplaceAll stops every registered controller and swaps in next, which the
// caller built from method. It is the only way controllers are installed, at
// startup and after a settings change alike.
//
// The swap happens under the registry lock so routing flips to the new set
// atomically; stopping and starting happen outside it. Both can block — Stop
// closes sockets, Start performs a handshake — and holding m.mu across them
// would stall every notification for the duration.
func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodConfig) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() previous := m.controllers
m.controllers[c.Name()] = c m.controllers = make(map[string]Controller, len(next))
postLog.Info("Controller registered: " + c.Name()) for _, ctrl := range next {
m.controllers[ctrl.Name()] = ctrl
}
m.mu.Unlock()
m.builtFrom.Store(&method)
names := make([]string, 0, len(next))
for _, ctrl := range next {
names = append(names, ctrl.Name())
}
postLog.Info("Controller set installed: " + strings.Join(names, ", "))
for _, ctrl := range previous {
ctrl.Stop()
}
// Start off the calling goroutine, the way startup does: Telegram's getMe
// and the OneBot WebSocket handshake would otherwise hold the settings
// request open for as long as they take. The new controllers are already
// routable, and each one can send before Start returns.
for _, ctrl := range next {
go func(ctrl Controller) {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Controller %s panicked on start: %v", ctrl.Name(), r))
}
}()
if err := ctrl.Start(); err != nil {
postLog.Error(fmt.Sprintf("Failed to start controller %s: %v", ctrl.Name(), err))
}
}(ctrl)
}
} }
// ShowBotInitMessage sends the bot initialization message to all enabled // ShowBotInitMessage sends the bot initialization message to all enabled
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do // bot controllers (QQ_OneBot and Telegram). Notification-only pipes that do
// not implement BotController are skipped. The message is typed // not implement BotController are skipped. The message is typed
// MessageTypeBotStarted so each controller can honor its members' per-recipient // MessageTypeBotStarted so each controller can honor its members' per-recipient
// EventBotStarted opt-out. It is rendered once per controller because the // EventBotStarted opt-out. It is rendered once per controller because the
@@ -202,12 +292,37 @@ func (m *Manager) ShowBotServerList() {
// controllers. It honors the per-node allow-list in bot_node_config.json: a // controllers. It honors the per-node allow-list in bot_node_config.json: a
// node whose enableStatusNotify is not true is skipped entirely, so no // node whose enableStatusNotify is not true is skipped entirely, so no
// controller (chat bots or notification pipes) broadcasts its change. // controller (chat bots or notification pipes) broadcasts its change.
//
// It also applies flap inhibition, the counterpart of the allow-list and read
// from the same file: an Offline notification for a node with a positive
// flap_tolerant is held back for that many seconds instead of being broadcast
// (see holdOffline). Because both gates live here, every controller is subject
// to them, chat bots and notification pipes alike.
func (m *Manager) NotifyStatusChange(change node.StatusChange) { func (m *Manager) NotifyStatusChange(change node.StatusChange) {
if !config.NodeStatusNotifyEnabled(change.UUID) { if !config.NodeStatusNotifyEnabled(change.UUID) {
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID)) postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
return return
} }
switch change.Event {
case "Offline":
if tolerance := m.flapTolerance(change.UUID); tolerance > 0 {
m.holdOffline(change, tolerance)
return
}
case "Online":
// A node that returns while its Offline is still held flaps: both
// halves of the pair are suppressed, and nothing is sent at all.
if m.releaseHeldOffline(change.UUID) {
return
}
}
m.dispatchStatusChange(change)
}
// dispatchStatusChange hands a change to every enabled controller.
func (m *Manager) dispatchStatusChange(change node.StatusChange) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -221,6 +336,116 @@ func (m *Manager) NotifyStatusChange(change node.StatusChange) {
} }
} }
// flapTolerance returns the flap-inhibition window for a node, honoring the
// test override when one is set.
func (m *Manager) flapTolerance(uuid string) time.Duration {
if m.flapToleranceFor != nil {
return m.flapToleranceFor(uuid)
}
return config.NodeFlapTolerant(uuid)
}
// holdOffline holds an Offline notification for the given window rather than
// broadcasting it. If the node returns within the window, releaseHeldOffline
// claims the held notification first and nothing is sent; otherwise the timer
// claims it and the Offline goes out late (see fireHeldOffline).
//
// A held notification is claimed by removing it from flapPending under flapMu,
// and the claim is decided by the identity of the map entry. That is what keeps
// the two racers — the timer and a returning Online — from both winning:
// whichever gets there first deletes the entry, and the loser finds it gone.
func (m *Manager) holdOffline(change node.StatusChange, tolerance time.Duration) {
m.flapMu.Lock()
if m.flapPending == nil {
m.flapPending = make(map[string]*flapPending)
}
// Defensive: the tracker reports transitions, so a second Offline should
// not arrive for a node already held, but if one does the older hold is
// superseded — its callback loses the claim below and drops the change.
if old := m.flapPending[change.UUID]; old != nil {
old.timer.Stop()
}
held := &flapPending{change: change}
held.timer = time.AfterFunc(tolerance, func() {
m.fireHeldOffline(change.UUID, held)
})
m.flapPending[change.UUID] = held
m.flapMu.Unlock()
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Offline for node %s held for %s (flap tolerance)", change.UUID, tolerance))
}
}
// flapEchoEnabled reports whether the flap-inhibition log lines are emitted,
// per the ShowFlapTolerantEcho debug flag. config.Current is nil before the
// first configuration load — which includes every unit test that drives the
// manager directly — so it is checked before the flag is read, the way the
// other per-module debug flags are (see internal/controller/pipes/qq_onebot).
func flapEchoEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowFlapTolerantEcho
}
// fireHeldOffline dispatches a held Offline once its window has elapsed. It
// claims the entry first and returns without sending if the hold was already
// released or superseded, so a node that came back in time never reports an
// Offline.
func (m *Manager) fireHeldOffline(uuid string, held *flapPending) {
m.flapMu.Lock()
if m.flapPending[uuid] != held {
// Already claimed by a returning Online, or replaced by a newer hold.
m.flapMu.Unlock()
return
}
delete(m.flapPending, uuid)
m.flapMu.Unlock()
// Re-check the allow-list: it may have been edited while the change was
// held, and years-long holds are possible in principle.
if !config.NodeStatusNotifyEnabled(uuid) {
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Held status change for node %s dropped: enableStatusNotify is not enabled", uuid))
}
return
}
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Node %s still offline after flap tolerance; reporting it now", uuid))
}
m.dispatchStatusChange(held.change)
}
// releaseHeldOffline claims and cancels a held Offline notification, reporting
// whether one was still pending. A true result means the node returned within
// its flap window, so the caller must suppress the Online as well.
//
// Stop's return value is deliberately ignored: false also covers "the timer has
// started firing but has not yet taken flapMu", which is not the same as
// "already dispatched". Removing the entry is what decides the race, and it
// makes the only possible outcomes "both halves sent" (the timer won, the node
// really did stay down past the deadline) or "neither sent" — never an Offline
// with no matching Online.
func (m *Manager) releaseHeldOffline(uuid string) bool {
m.flapMu.Lock()
defer m.flapMu.Unlock()
if m.flapPending == nil {
return false
}
held := m.flapPending[uuid]
if held == nil {
return false
}
held.timer.Stop()
delete(m.flapPending, uuid)
if flapEchoEnabled() {
postLog.Debug(fmt.Sprintf("Node %s returned within flap tolerance; suppressing both halves", uuid))
}
return true
}
// NotifyAlert delivers an alert to the named pipes only, and returns the names // 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 // 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 // send is reported through the returned error instead of stopping the delivery
@@ -274,8 +499,19 @@ func (m *Manager) IsMarkdown(pipeName string) bool {
return ctrl.IsMarkdown() return ctrl.IsMarkdown()
} }
// StopAll stops all registered controllers. // StopAll stops all registered controllers. Held Offline notifications are
// dropped first: their timers would otherwise fire against a shutting-down
// controller set. A timer that has already claimed its entry can still be
// dispatching concurrently, which is harmless — it reads the same registry
// under the same lock.
func (m *Manager) StopAll() { func (m *Manager) StopAll() {
m.flapMu.Lock()
for uuid, held := range m.flapPending {
held.timer.Stop()
delete(m.flapPending, uuid)
}
m.flapMu.Unlock()
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
for _, ctrl := range m.controllers { for _, ctrl := range m.controllers {
+446
View File
@@ -0,0 +1,446 @@
package controller
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
)
// fakeController records the lifecycle calls it receives so a test can assert
// what ReplaceAll did to it. Start signals on started, because ReplaceAll starts
// controllers off the calling goroutine.
type fakeController struct {
name string
started chan struct{}
mu sync.Mutex
starts int
stops int
}
func newFake(name string) *fakeController {
return &fakeController{name: name, started: make(chan struct{}, 4)}
}
func (f *fakeController) Name() string { return f.name }
func (f *fakeController) IsEnabled() bool { return true }
func (f *fakeController) IsMarkdown() bool { return false }
func (f *fakeController) Start() error {
f.mu.Lock()
f.starts++
f.mu.Unlock()
select {
case f.started <- struct{}{}:
default:
}
return nil
}
func (f *fakeController) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
f.stops++
}
func (f *fakeController) lifecycle() (starts, stops int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.starts, f.stops
}
func (f *fakeController) SendStatusChange(node.StatusChange) error { return nil }
func (f *fakeController) SendServerList(string, string) error { return nil }
func (f *fakeController) SendExecuteResult(string, string, string, string) error { return nil }
func (f *fakeController) SendAlert(Alert) error { return nil }
// waitStarted blocks until the controller's Start has run.
func (f *fakeController) waitStarted(t *testing.T) {
t.Helper()
select {
case <-f.started:
case <-time.After(5 * time.Second):
t.Fatalf("controller %s was never started", f.name)
}
}
func newTestManager() *Manager {
return &Manager{controllers: make(map[string]Controller)}
}
// routedTo returns the controller the manager currently routes the given name
// to.
func (m *Manager) routedTo(name string) Controller {
m.mu.RLock()
defer m.mu.RUnlock()
return m.controllers[name]
}
// heldOffline returns the Offline notification the manager is currently holding
// for the given node, if any. It lets a test drive the hold/fire/release race
// deterministically instead of racing a real timer.
func (m *Manager) heldOffline(uuid string) *flapPending {
m.flapMu.Lock()
defer m.flapMu.Unlock()
return m.flapPending[uuid]
}
// recordingController keeps every status change it is handed, so a test can
// assert on what actually reached the controllers. The embedded fakeController
// satisfies the rest of the Controller interface.
type recordingController struct {
*fakeController
changes chan node.StatusChange
}
func newRecording(name string) *recordingController {
return &recordingController{
fakeController: newFake(name),
changes: make(chan node.StatusChange, 16),
}
}
func (r *recordingController) SendStatusChange(change node.StatusChange) error {
r.changes <- change
return nil
}
// awaitChange blocks until a status change reaches the controller, failing the
// test rather than hanging if none does.
func awaitChange(t *testing.T, rec *recordingController, what string) node.StatusChange {
t.Helper()
select {
case change := <-rec.changes:
return change
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for the %s notification", what)
return node.StatusChange{}
}
}
// expectNoChange asserts that no status change reaches the controller for the
// given window. Only a negative assertion needs a real wait; the manager has no
// observable "decided to stay silent" event.
func expectNoChange(t *testing.T, rec *recordingController, window time.Duration, what string) {
t.Helper()
select {
case change := <-rec.changes:
t.Fatalf("unexpected %s notification: %+v", what, change)
case <-time.After(window):
}
}
func offlineChange(uuid string) node.StatusChange {
return node.StatusChange{Event: "Offline", UUID: uuid, Name: uuid, OldOnline: true, NewOnline: false}
}
func onlineChange(uuid string) node.StatusChange {
return node.StatusChange{Event: "Online", UUID: uuid, Name: uuid, OldOnline: false, NewOnline: true}
}
// flapTestManager returns a manager with a recording controller installed and
// the flap window stubbed to tolerance, so the tests never touch the global
// bot_node_config.json or wait on real seconds. ReplaceAll makes the controller
// routable before it starts it, so no start handshake is needed here.
func flapTestManager(tolerance time.Duration) (*Manager, *recordingController) {
m := newTestManager()
m.flapToleranceFor = func(string) time.Duration { return tolerance }
rec := newRecording("recorder")
m.ReplaceAll([]Controller{rec}, config.ControllerMethodConfig{})
return m, rec
}
// TestFlapInhibitionSuppressesBothHalves is the core guarantee: a node that
// returns inside its flap window produces no notification at all, so a flapping
// node does not flood the controllers.
func TestFlapInhibitionSuppressesBothHalves(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("flappy"))
m.NotifyStatusChange(onlineChange("flappy"))
expectNoChange(t, rec, 300*time.Millisecond, "suppressed offline/online pair")
if held := m.heldOffline("flappy"); held != nil {
t.Error("the held offline notification was not released when the node returned")
}
}
// TestFlapInhibitionReportsLateOffline: a node still down when the window
// elapses reports Offline as usual, however late the notification is.
func TestFlapInhibitionReportsLateOffline(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("down"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" || change.UUID != "down" {
t.Errorf("got %+v, want the Offline change for node down", change)
}
if held := m.heldOffline("down"); held != nil {
t.Error("the held notification was still pending after it was dispatched")
}
}
// TestFlapInhibitionReportsRecoveryAfterWindow: once the Offline has gone out,
// the node's eventual return is a genuine recovery and must be reported. This
// is the honest half of the "both or neither, never Offline alone" rule.
func TestFlapInhibitionReportsRecoveryAfterWindow(t *testing.T) {
m, rec := flapTestManager(50 * time.Millisecond)
m.NotifyStatusChange(offlineChange("down"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Fatalf("got %+v, want the Offline change", change)
}
m.NotifyStatusChange(onlineChange("down"))
if change := awaitChange(t, rec, "online"); change.Event != "Online" {
t.Errorf("got %+v, want the Online change", change)
}
}
// TestFlapInhibitionDisabledWithoutTolerance guards the previous behavior: with
// no window (the default for every node), an Offline still goes out at once.
func TestFlapInhibitionDisabledWithoutTolerance(t *testing.T) {
m, rec := flapTestManager(0)
m.NotifyStatusChange(offlineChange("plain"))
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Errorf("got %+v, want the Offline change", change)
}
if held := m.heldOffline("plain"); held != nil {
t.Error("a node without a flap window must not have a held notification")
}
}
// TestFlapInhibitionHonorsEnableStatusNotify pins the gate ordering: a node
// whose notifications are switched off is skipped before any hold is taken, so
// it cannot report a late Offline either.
func TestFlapInhibitionHonorsEnableStatusNotify(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bot_node_config.json")
content := `{"muted": {"enableStatusNotify": false, "flap_tolerant": 600}}`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write node config: %v", err)
}
if err := config.LoadBotNodeConfig(path); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
// Leave nothing behind for the other tests in this package, which reach the
// same package-global node config.
t.Cleanup(func() {
if err := config.LoadBotNodeConfig(filepath.Join(dir, "empty.json")); err != nil {
t.Errorf("reset node config: %v", err)
}
})
// The stub reports a window, so only the allow-list can be responsible for
// the silence.
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("muted"))
expectNoChange(t, rec, 100*time.Millisecond, "offline for a muted node")
if held := m.heldOffline("muted"); held != nil {
t.Error("a muted node must not have its Offline held")
}
}
// TestFlapInhibitionClaimOrder pins both sides of the hold/fire/release race
// deterministically, with a window long enough that the real timer never fires:
// whichever claims the held notification first wins, and the loser sees it gone.
func TestFlapInhibitionClaimOrder(t *testing.T) {
t.Run("release first suppresses the pair", func(t *testing.T) {
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
held := m.heldOffline("node")
if held == nil {
t.Fatal("the offline notification was not held")
}
if !m.releaseHeldOffline("node") {
t.Fatal("releaseHeldOffline reported no pending notification")
}
// The timer goroutine loses the claim and must not dispatch.
m.fireHeldOffline("node", held)
expectNoChange(t, rec, 100*time.Millisecond, "notification after a release")
})
t.Run("fire first reports the offline alone", func(t *testing.T) {
m, rec := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
held := m.heldOffline("node")
if held == nil {
t.Fatal("the offline notification was not held")
}
m.fireHeldOffline("node", held)
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
t.Errorf("got %+v, want the Offline change", change)
}
if m.releaseHeldOffline("node") {
t.Error("a fired notification must not be claimable again")
}
})
}
// TestStopAllDropsHeldNotifications: a hold must not outlive the controllers it
// was meant for.
func TestStopAllDropsHeldNotifications(t *testing.T) {
m, _ := flapTestManager(time.Hour)
m.NotifyStatusChange(offlineChange("node"))
m.StopAll()
if held := m.heldOffline("node"); held != nil {
t.Error("StopAll left a held offline notification armed")
}
}
func TestReplaceAllInstallsAndStarts(t *testing.T) {
m := newTestManager()
alpha, beta := newFake("alpha"), newFake("beta")
m.ReplaceAll([]Controller{alpha, beta}, config.ControllerMethodConfig{})
alpha.waitStarted(t)
beta.waitStarted(t)
if got := m.routedTo("alpha"); got != Controller(alpha) {
t.Errorf("alpha is not routable after ReplaceAll: %v", got)
}
if got := m.routedTo("beta"); got != Controller(beta) {
t.Errorf("beta is not routable after ReplaceAll: %v", got)
}
}
// TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the
// old controllers must be shut down, or a rebuilt onebot or Telegram controller
// would leave its previous connection running.
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
m := newTestManager()
outgoing := newFake("alpha")
m.ReplaceAll([]Controller{outgoing}, config.ControllerMethodConfig{})
outgoing.waitStarted(t)
incoming := newFake("alpha")
m.ReplaceAll([]Controller{incoming}, config.ControllerMethodConfig{})
incoming.waitStarted(t)
if starts, stops := outgoing.lifecycle(); starts != 1 || stops != 1 {
t.Errorf("outgoing controller lifecycle = %d starts / %d stops, want 1/1", starts, stops)
}
if _, stops := incoming.lifecycle(); stops != 0 {
t.Errorf("incoming controller was stopped %d times", stops)
}
if got := m.routedTo("alpha"); got != Controller(incoming) {
t.Error("routing still points at the outgoing controller")
}
}
func TestReplaceAllDropsChannelsLeftOut(t *testing.T) {
m := newTestManager()
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
m.ReplaceAll([]Controller{newFake("beta")}, config.ControllerMethodConfig{})
if got := m.routedTo("alpha"); got != nil {
t.Errorf("a channel missing from the new set is still routable: %v", got)
}
if got := m.routedTo("beta"); got == nil {
t.Error("the surviving channel is not routable")
}
}
func TestNeedsRebuild(t *testing.T) {
m := newTestManager()
base := config.ControllerMethodConfig{
Email: config.EmailConfig{Enabled: true, SMTPPort: 587, To: []string{"a@example.com"}},
}
if !m.NeedsRebuild(base) {
t.Error("a manager with nothing installed must report that a rebuild is needed")
}
m.ReplaceAll(nil, base)
if m.NeedsRebuild(base) {
t.Error("the settings the set was built from must not ask for another rebuild")
}
changed := base
changed.Email.Enabled = false
if !m.NeedsRebuild(changed) {
t.Error("a changed email setting must ask for a rebuild")
}
// The section carries maps and slices, so it is compared by value rather
// than by identity: equal content must not trigger a rebuild.
withHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
}
m.ReplaceAll(nil, withHeaders)
equalHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
}
if m.NeedsRebuild(equalHeaders) {
t.Error("equal header maps must not ask for a rebuild")
}
differentHeaders := config.ControllerMethodConfig{
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "other"}},
}
if !m.NeedsRebuild(differentHeaders) {
t.Error("a changed header must ask for a rebuild")
}
}
// TestReplaceAllDuringNotification drives ReplaceAll while notifications are
// being routed. Run with -race: the swap replaces the map the routing path
// reads, which is what the registry lock exists to make safe.
func TestReplaceAllDuringNotification(t *testing.T) {
m := newTestManager()
m.ReplaceAll([]Controller{newFake("alpha")}, config.ControllerMethodConfig{})
var readers, writers sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 3; i++ {
readers.Add(1)
go func() {
defer readers.Done()
for {
select {
case <-stop:
return
default:
}
m.NotifyStatusChange(node.StatusChange{UUID: "u1", Name: "alpha", Event: "Online"})
_ = m.IsMarkdown("alpha")
}
}()
}
writers.Add(1)
go func() {
defer writers.Done()
for i := 0; i < 20; i++ {
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
}
}()
// Let the writer finish, then release the readers: they only return once
// stop is closed.
writers.Wait()
close(stop)
readers.Wait()
if got := m.routedTo("beta"); got == nil {
t.Error("the last installed set is not routable")
}
}
+20 -7
View File
@@ -2,6 +2,7 @@ package pipes
import ( import (
"fmt" "fmt"
"net"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
@@ -14,22 +15,34 @@ import (
) )
// EmailController handles email notifications via SMTP. // EmailController handles email notifications via SMTP.
//
// cfg is written once, by the constructor, and never again: a controller that
// needs different settings is replaced wholesale by the manager rather than
// reconfigured in place, so the send methods can read it without locking.
type EmailController struct { type EmailController struct {
cfg config.EmailConfig cfg config.EmailConfig
} }
// NewEmailController creates a new Email controller. // NewEmailController creates a new Email controller.
func NewEmailController(cfg config.EmailConfig) *EmailController { func NewEmailController(cfg config.EmailConfig) *EmailController {
if cfg.NetworkUseProxy { applyEmailProxy(cfg.NetworkUseProxy)
// Route SMTP through the HTTP CONNECT proxy. NetDialTimeout is
// gomail's documented hook for overriding how the SMTP connection is
// dialed. There is a single global email channel, so overriding it
// unconditionally when the flag is set is safe.
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
}
return &EmailController{cfg: cfg} return &EmailController{cfg: cfg}
} }
// applyEmailProxy routes SMTP through the HTTP CONNECT proxy, or restores a
// direct dial. gomail exposes the dial path as the package-level
// NetDialTimeout, whose own default is net.DialTimeout, so turning the proxy
// off has to put that back rather than leave the hook in place. There is a
// single global email channel, so setting a package-level hook here is
// unambiguous.
func applyEmailProxy(useProxy bool) {
if useProxy {
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
return
}
gomail.NetDialTimeout = net.DialTimeout
}
// Name returns the controller name. // Name returns the controller name.
func (e *EmailController) Name() string { func (e *EmailController) Name() string {
return "email" return "email"
@@ -0,0 +1,54 @@
package pipes
import (
"net"
"reflect"
"testing"
gomail "gopkg.in/mail.v2"
"nukumizu-backend/config"
)
// dialerIsDefault reports whether gomail still dials directly. Function values
// are not comparable in Go, so the code pointers are compared instead.
func dialerIsDefault() bool {
return reflect.ValueOf(gomail.NetDialTimeout).Pointer() ==
reflect.ValueOf(net.DialTimeout).Pointer()
}
// TestApplyEmailProxyRestoresDefaultDialer covers the case the constructor used
// to get wrong. gomail exposes its dial hook as a package-level variable with no
// unset, and the code only installed the proxy dialer when the flag was set, so
// turning networkUseProxy back off left SMTP tunnelled through a proxy nobody
// had asked for — with no way to undo it short of a restart.
func TestApplyEmailProxyRestoresDefaultDialer(t *testing.T) {
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
applyEmailProxy(true)
if dialerIsDefault() {
t.Fatal("applyEmailProxy(true) did not install the proxy dialer")
}
applyEmailProxy(false)
if !dialerIsDefault() {
t.Error("applyEmailProxy(false) left the proxy dialer in place")
}
}
// TestNewEmailControllerAppliesProxySetting pins that the dialer follows the
// settings a controller is built with, which is what makes a rebuilt controller
// pick up a changed proxy flag.
func TestNewEmailControllerAppliesProxySetting(t *testing.T) {
t.Cleanup(func() { gomail.NetDialTimeout = net.DialTimeout })
NewEmailController(config.EmailConfig{NetworkUseProxy: true})
if dialerIsDefault() {
t.Error("a controller built with networkUseProxy: true did not install the proxy dialer")
}
NewEmailController(config.EmailConfig{NetworkUseProxy: false})
if !dialerIsDefault() {
t.Error("a controller rebuilt with networkUseProxy: false left the proxy dialer in place")
}
}
+5
View File
@@ -15,6 +15,11 @@ import (
) )
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server. // NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
//
// cfg and httpClient are written once, by the constructor, and never again: a
// controller that needs different settings is replaced wholesale by the manager
// rather than reconfigured in place, so the send methods can read them without
// locking.
type NtfyController struct { type NtfyController struct {
cfg config.NtfyConfig cfg config.NtfyConfig
httpClient *http.Client httpClient *http.Client
@@ -1,330 +0,0 @@
package qq_napcat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
// 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.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// Client is the HTTP + WebSocket client for a single NapCat instance.
// It both listens for incoming OneBot events over WebSocket and issues
// outbound NapCat API calls over HTTP.
type Client struct {
addr string
port string
token string
useProxy bool
httpClient *http.Client
connMu sync.Mutex
conn *websocket.Conn
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a NapCat client for the given host/port/token. When
// useProxy is set, HTTP API calls and the WebSocket connection are routed
// through the system-wide network proxy.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
httpClient: netproxy.HTTPClient(useProxy, 30*time.Second),
stopCh: make(chan struct{}),
}
}
func (c *Client) baseURL() string {
return fmt.Sprintf("http://%s:%s", c.addr, c.port)
}
// sendRequest performs an HTTP call to NapCat, injecting access_token into POST
// bodies AND setting Authorization: Bearer (compatible with HTTP Server adapters
// created in the NapCat Web UI).
func (c *Client) sendRequest(method, endpoint string, body []byte) ([]byte, error) {
url := c.baseURL() + endpoint
// For POST requests, inject access_token into the JSON body before sending.
var finalBody []byte
if body != nil && c.token != "" {
var bodyMap map[string]interface{}
if err := json.Unmarshal(body, &bodyMap); err == nil {
bodyMap["access_token"] = c.token
finalBody, _ = json.Marshal(bodyMap)
}
}
if finalBody == nil {
finalBody = body
}
var req *http.Request
var err error
if finalBody != nil {
req, err = http.NewRequest(method, url, bytes.NewReader(finalBody))
} else {
req, err = http.NewRequest(method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request to napcat: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read napcat response: %w", err)
}
return respBody, nil
}
// parseResponse unmarshals NapCat's raw response body into an APIResponse.
func (c *Client) parseResponse(respBody []byte, caller string) (*APIResponse, error) {
var napcatResp APIResponse
if err := json.Unmarshal(respBody, &napcatResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal napcat response (%s): %w\n%s", caller, err, string(respBody))
}
return &napcatResp, nil
}
// SendMsg sends a message via NapCat.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var endpoint string
var napcatReq map[string]interface{}
switch targetType {
case "group":
endpoint = "/send_group_msg"
napcatReq = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
endpoint = "/send_private_msg"
napcatReq = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
reqBody, err := json.Marshal(napcatReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
}
respBody, err := c.sendRequest(http.MethodPost, endpoint, reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "SendMsg")
}
// RecallMsg recalls (deletes) a sent message via NapCat.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"message_id": msgID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
}
respBody, err := c.sendRequest(http.MethodPost, "/delete_msg", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "RecallMsg")
}
// GetGroupList retrieves the list of joined groups from NapCat.
func (c *Client) GetGroupList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_group_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupList")
}
// GetGroupInfo retrieves detailed information about a specific group from NapCat.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"group_id": groupID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
}
respBody, err := c.sendRequest(http.MethodPost, "/get_group_info", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupInfo")
}
// GetFriendsList retrieves the friends list from NapCat.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_friend_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetFriendsList")
}
// Listen connects to the NapCat WebSocket server and calls onEvent for every raw
// message. It blocks forever, reconnecting every 5s after a disconnect, until
// Stop is called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[Napcat] WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(5 * time.Second):
}
}
}
// listenOnce connects to the NapCat WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
wsURL := fmt.Sprintf("ws://%s:%s/", c.addr, c.port)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info(fmt.Sprintf("[Napcat] Connected to NapCat WebSocket at %s", wsURL))
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// Stop closes any open WebSocket connection and unblocks the Listen reconnect loop.
func (c *Client) Stop() {
c.stopOnce.Do(func() { close(c.stopCh) })
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
}
@@ -0,0 +1,562 @@
package qq_onebot
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
const (
// onebotReconnectDelay is the pause between reconnect attempts, shared by the
// event socket and the API socket.
onebotReconnectDelay = 5 * time.Second
// onebotAPITimeout bounds one callAPI response wait. It matches the timeout the
// removed HTTP client (netproxy.HTTPClient(useProxy, 30s)) imposed, so callers
// see the same worst-case latency they always did.
onebotAPITimeout = 30 * time.Second
// onebotConnectWait bounds how long callAPI waits for the API socket to come up
// when it is not connected yet. It is deliberately short: the startup bot
// initialization message is sent on the main goroutine, and a down OneBot
// server must not stall HTTP-server startup for the full API timeout.
onebotConnectWait = 5 * time.Second
// onebotWriteTimeout bounds a single socket write so a wedged peer cannot block
// the caller (and, through writeMu, every other writer) indefinitely.
onebotWriteTimeout = 10 * time.Second
)
// actionLogEnabled reports whether the OneBot API methods echo each action they
// send, per the showOneBotAction toggle. Unlike the WebSocket message path in
// qq.go it deliberately does not also require debugMode: these methods have
// always logged on this toggle alone, and preserving that is intentional.
func actionLogEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowOneBotAction
}
// APIResponse mirrors OneBot's API response envelope. Echo is the correlation id
// the client sent, echoed back by the server; callAPI uses it internally to match
// a response to its request. Data stays raw so callers decode get_group_list /
// get_friend_list arrays themselves.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Echo json.RawMessage `json:"echo"`
}
// apiRequest is one OneBot API call written to the API socket. A struct rather
// than a map guarantees Echo is encoded as a JSON string, which is what the
// server sends back verbatim.
type apiRequest struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
Echo string `json:"echo"`
}
// apiResult carries one decoded API response (or a transport error) from the API
// read loop back to the callAPI invocation waiting on its echo.
type apiResult struct {
resp *APIResponse
err error
}
// Client is the WebSocket client for a single OneBot instance. It uses two
// forward-WebSocket connections, per the OneBot 11 split between events and API
// calls:
//
// - the event socket ("/") only reads: one goroutine feeds every frame to
// onEvent. Nothing is ever written to it.
// - the API socket ("/api") only issues actions: callAPI writes a
// {"action","params","echo"} frame there, and its dedicated read loop matches
// the reply by echo.
//
// They must be separate: qq.go's event handler replies with SendMsg from inside
// the event read loop, so reading API responses on that same socket would
// deadlock (the loop would be blocked in the handler, waiting on a response only
// it could deliver).
type Client struct {
addr string
port string
token string
useProxy bool
// Event socket ("/"): one reader (listenOnce), closed only by Stop or its own
// cleanup, never written to.
connMu sync.Mutex
conn *websocket.Conn
// API socket ("/api"). apiMu guards the pointer, the epoch channel and the
// pending table; writeMu serializes the actual socket writes, so apiMu is
// never held across I/O (which would stall response dispatch behind a slow
// write).
apiMu sync.Mutex
apiConn *websocket.Conn
apiChanged chan struct{} // closed and replaced on every connect/disconnect
writeMu sync.Mutex
pending map[string]chan apiResult
echoSeq atomic.Uint64
stopped bool
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a OneBot client for the given host/port/token. When useProxy
// is set, both WebSocket connections are routed through the system-wide network
// proxy. It performs no I/O; Listen starts the sockets.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
apiChanged: make(chan struct{}),
pending: make(map[string]chan apiResult),
stopCh: make(chan struct{}),
}
}
// dialWS opens one forward-WebSocket connection to the given path. The token is
// passed both as an access_token query parameter and as an Authorization: Bearer
// header (forward adapters check one or the other), and the dial goes through the
// system proxy when useProxy is set.
func (c *Client) dialWS(path string) (*websocket.Conn, error) {
wsURL := fmt.Sprintf("ws://%s:%s%s", c.addr, c.port, path)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
return nil, fmt.Errorf("failed to connect to onebot websocket %s: %w", path, err)
}
return conn, nil
}
// SendMsg sends a message via OneBot.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var action string
var params map[string]interface{}
switch targetType {
case "group":
action = "send_group_msg"
params = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
action = "send_private_msg"
params = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] SendMsg -> %s (%s): %s", action, targetType, message))
}
return c.callAPI(action, params)
}
// RecallMsg recalls (deletes) a sent message via OneBot.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] RecallMsg -> delete_msg: %d", msgID))
}
return c.callAPI("delete_msg", map[string]interface{}{"message_id": msgID})
}
// GetGroupList retrieves the list of joined groups from OneBot.
func (c *Client) GetGroupList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetGroupList -> get_group_list")
}
return c.callAPI("get_group_list", nil)
}
// GetGroupInfo retrieves detailed information about a specific group from OneBot.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] GetGroupInfo -> get_group_info: %d", groupID))
}
return c.callAPI("get_group_info", map[string]interface{}{"group_id": groupID})
}
// GetFriendsList retrieves the friends list from OneBot.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetFriendsList -> get_friend_list")
}
return c.callAPI("get_friend_list", nil)
}
// callAPI issues one OneBot action over the API socket and waits for its reply.
//
// It returns a Go error only for transport failures (not connected, write error,
// disconnect, timeout, Stop). An OneBot-level failure (status "failed", retcode
// 1xxx) is returned as an ordinary (*APIResponse, nil), matching the behavior of
// the HTTP client this replaces.
func (c *Client) callAPI(action string, params map[string]interface{}) (*APIResponse, error) {
// The spec allows omitting params entirely, but some adapters reject a null
// value, so a bodyless action still sends an empty object.
if params == nil {
params = map[string]interface{}{}
}
echo := strconv.FormatUint(c.echoSeq.Add(1), 10)
payload, err := json.Marshal(apiRequest{Action: action, Params: params, Echo: echo})
if err != nil {
return nil, fmt.Errorf("failed to marshal onebot request: %w", err)
}
// Buffered cap 1: the API read loop and failPending can each deliver at most
// once, and delivering must never block the read loop.
ch := make(chan apiResult, 1)
c.apiMu.Lock()
if c.stopped {
c.apiMu.Unlock()
return nil, fmt.Errorf("onebot client stopped")
}
c.pending[echo] = ch
c.apiMu.Unlock()
// Deregister on every exit path. dispatchAPI deletes the entry too, so this is
// a no-op when the response already arrived.
defer func() {
c.apiMu.Lock()
delete(c.pending, echo)
c.apiMu.Unlock()
}()
conn, err := c.waitForAPIConn()
if err != nil {
return nil, err
}
if err := c.writeAPI(conn, payload); err != nil {
return nil, fmt.Errorf("failed to send onebot request: %w", err)
}
timer := time.NewTimer(onebotAPITimeout)
defer timer.Stop()
select {
case res := <-ch:
return res.resp, res.err
case <-timer.C:
return nil, fmt.Errorf("onebot API %s timed out after %s", action, onebotAPITimeout)
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
// waitForAPIConn returns the live API connection, waiting (bounded by
// onebotConnectWait, or until Stop) for apiLoop to establish one when it is not
// connected yet. It re-checks the state on every apiChanged transition, so a
// closed channel left over from a previous connection cannot fool it.
func (c *Client) waitForAPIConn() (*websocket.Conn, error) {
timer := time.NewTimer(onebotConnectWait)
defer timer.Stop()
for {
c.apiMu.Lock()
conn := c.apiConn
changed := c.apiChanged
stopped := c.stopped
c.apiMu.Unlock()
if conn != nil {
return conn, nil
}
if stopped {
return nil, fmt.Errorf("onebot client stopped")
}
select {
case <-changed:
// Connection state changed; re-check.
case <-timer.C:
return nil, fmt.Errorf("onebot API WebSocket not connected")
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
}
// writeAPI serializes one request frame onto conn under writeMu, with a write
// deadline. gorilla/websocket panics on concurrent writes, and both the event
// handler and every SendMessage path call callAPI, so concurrency is the norm.
func (c *Client) writeAPI(conn *websocket.Conn, payload []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(onebotWriteTimeout)); err != nil {
return err
}
return conn.WriteMessage(websocket.TextMessage, payload)
}
// Listen connects the event WebSocket and calls onEvent for every raw message. It
// starts the API socket loop as well, since NewClient is side-effect-free and
// Listen is the single entry point the controller drives. It blocks forever,
// reconnecting every onebotReconnectDelay after a disconnect, until Stop is
// called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
go c.apiLoop()
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[qq_onebot] Event WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// listenOnce connects to the OneBot event WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
conn, err := c.dialWS("/")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot event WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info("[qq_onebot] Connected to OneBot event WebSocket")
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Event WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// apiLoop owns the API socket: it keeps a connection to "/api" up, dispatching
// any reply its read loop receives and reconnecting every onebotReconnectDelay.
// It exits on Stop.
func (c *Client) apiLoop() {
for {
select {
case <-c.stopCh:
return
default:
}
c.runAPIConnection()
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// runAPIConnection dials "/api", publishes the connection to waiters, and reads
// response frames until the connection drops or Stop is called. On the way out it
// marks the socket down (waking waiters and failing in-flight calls) so a caller
// does not wait the full timeout for a reply that can no longer arrive.
func (c *Client) runAPIConnection() {
conn, err := c.dialWS("/api")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot API WebSocket: %v", err))
return
}
c.apiMu.Lock()
c.apiConn = conn
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
postLog.Info("[qq_onebot] Connected to OneBot API WebSocket")
c.readAPILoop(conn)
c.apiMu.Lock()
if c.apiConn == conn {
c.apiConn = nil
}
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
conn.Close()
c.failPending(fmt.Errorf("onebot API WebSocket disconnected"))
}
// readAPILoop reads response frames from the API socket until the connection
// drops or Stop is called. It is the only reader of that connection.
func (c *Client) readAPILoop(conn *websocket.Conn) {
for {
select {
case <-c.stopCh:
return
default:
}
_, data, err := conn.ReadMessage()
if err != nil {
select {
case <-c.stopCh:
// Stopping: the read error is just the closed socket.
default:
postLog.Error(fmt.Sprintf("[qq_onebot] API WebSocket read error: %v", err))
}
return
}
c.dispatchAPI(data)
}
}
// dispatchAPI decodes one inbound frame on the API socket. A frame with no usable
// echo is not an API response and is ignored (the event socket is the only place
// events are expected); otherwise the pending channel registered for that echo
// receives the envelope and is removed, both under apiMu.
func (c *Client) dispatchAPI(raw []byte) {
var resp APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
postLog.Debug("[qq_onebot] Failed to parse OneBot API response: " + err.Error())
return
}
key, ok := echoKey(resp.Echo)
if !ok {
postLog.Debug("[qq_onebot] Ignoring non-response frame on OneBot API WebSocket: " + string(raw))
return
}
c.apiMu.Lock()
ch := c.pending[key]
delete(c.pending, key)
c.apiMu.Unlock()
if ch != nil {
ch <- apiResult{resp: &resp}
}
}
// echoKey normalizes an echo value to its pending-map key. An absent or null echo
// yields ok=false, i.e. the frame is not an API response. A JSON string is
// unquoted; any other type falls back to its verbatim bytes so an adapter that
// returns the id unquoted still matches.
func echoKey(raw json.RawMessage) (string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, true
}
return string(raw), true
}
// failPending delivers err to every call waiting on a response and clears the
// table, so a dropped API socket unblocks its callers immediately. Called on
// disconnect and by Stop; safe to call repeatedly.
func (c *Client) failPending(err error) {
c.apiMu.Lock()
pending := c.pending
c.pending = make(map[string]chan apiResult)
c.apiMu.Unlock()
for _, ch := range pending {
// Buffered cap 1, and each entry is owned exactly once, so this never blocks.
ch <- apiResult{err: err}
}
}
// Stop closes both WebSocket connections and unblocks the event reconnect loop,
// the API loop and every in-flight callAPI. It is idempotent and safe to call
// even when Listen was never started.
func (c *Client) Stop() {
c.stopOnce.Do(func() {
close(c.stopCh)
c.apiMu.Lock()
c.stopped = true
apiConn := c.apiConn
c.apiMu.Unlock()
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
if apiConn != nil {
apiConn.Close()
}
c.failPending(fmt.Errorf("onebot client stopped"))
})
}
@@ -0,0 +1,235 @@
package qq_onebot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeOneBot is a minimal OneBot 11 forward-WebSocket server. "/api" answers
// every action call by echoing its echo with a fixed ok response; "/" accepts the
// event socket and hands it to the test so events can be pushed on demand.
type fakeOneBot struct {
upgrader websocket.Upgrader
server *httptest.Server
apiRequests chan fakeAPIRequest
eventConns chan *websocket.Conn
}
type fakeAPIRequest struct {
Action string `json:"action"`
Params json.RawMessage `json:"params"`
Echo json.RawMessage `json:"echo"`
}
func newFakeOneBot(t *testing.T) *fakeOneBot {
t.Helper()
f := &fakeOneBot{
apiRequests: make(chan fakeAPIRequest, 16),
eventConns: make(chan *websocket.Conn, 16),
}
mux := http.NewServeMux()
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var req fakeAPIRequest
if err := json.Unmarshal(data, &req); err != nil {
continue
}
f.apiRequests <- req
resp, _ := json.Marshal(map[string]json.RawMessage{
"status": json.RawMessage(`"ok"`),
"retcode": json.RawMessage(`0`),
"data": json.RawMessage(`{"message_id":123}`),
"echo": req.Echo,
})
if err := conn.WriteMessage(websocket.TextMessage, resp); err != nil {
return
}
}
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
f.eventConns <- conn
// Keep the event socket open until the client goes away.
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
// client returns a Client pointed at this fake server with the proxy disabled.
func (f *fakeOneBot) client(t *testing.T) *Client {
t.Helper()
u, err := url.Parse(f.server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
return NewClient(u.Hostname(), u.Port(), "", false)
}
// eventConn waits for the event socket to be established and returns it.
func (f *fakeOneBot) eventConn(t *testing.T) *websocket.Conn {
t.Helper()
select {
case conn := <-f.eventConns:
return conn
case <-time.After(3 * time.Second):
t.Fatal("event WebSocket never connected")
return nil
}
}
// TestSendMsgOverWebSocket checks an API call is issued over the "/api" socket
// and its response is decoded.
func TestSendMsgOverWebSocket(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
resp, err := c.SendMsg("group", 12345, "hello", false, 0)
if err != nil {
t.Fatalf("SendMsg: %v", err)
}
if resp.Status != "ok" || resp.RetCode != 0 {
t.Fatalf("unexpected response: %+v", resp)
}
var data struct {
MessageID int64 `json:"message_id"`
}
if err := json.Unmarshal(resp.Data, &data); err != nil {
t.Fatalf("decode data: %v", err)
}
if data.MessageID != 123 {
t.Fatalf("message_id = %d, want 123", data.MessageID)
}
req := <-f.apiRequests
if req.Action != "send_group_msg" {
t.Fatalf("action = %q, want send_group_msg", req.Action)
}
var params map[string]any
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params["group_id"].(float64) != 12345 || params["message"].(string) != "hello" {
t.Fatalf("params = %v", params)
}
var echo string
if err := json.Unmarshal(req.Echo, &echo); err != nil {
t.Fatalf("echo is not a JSON string: %s", req.Echo)
}
}
// TestBodylessActionSendsEmptyParams checks actions without arguments still send
// a params object (never null), which some adapters reject.
func TestBodylessActionSendsEmptyParams(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
if _, err := c.GetGroupList(); err != nil {
t.Fatalf("GetGroupList: %v", err)
}
req := <-f.apiRequests
if req.Action != "get_group_list" {
t.Fatalf("action = %q, want get_group_list", req.Action)
}
if string(req.Params) != "{}" {
t.Fatalf("params = %s, want {}", req.Params)
}
}
// TestSendMsgFromEventCallbackDoesNotDeadlock is the regression test for the
// reason the client uses two sockets: the event handler replies with SendMsg from
// inside the event read loop, so a response read on that same connection would
// never arrive.
func TestSendMsgFromEventCallbackDoesNotDeadlock(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
type result struct {
resp *APIResponse
err error
}
done := make(chan result, 1)
go c.Listen(func(raw []byte) {
resp, err := c.SendMsg("group", 1, "pong", false, 0)
done <- result{resp, err}
})
defer c.Stop()
evConn := f.eventConn(t)
event := []byte(`{"post_type":"message","message_type":"group","group_id":1,"user_id":2,"raw_message":"/ping","self_id":9}`)
if err := evConn.WriteMessage(websocket.TextMessage, event); err != nil {
t.Fatalf("write event: %v", err)
}
select {
case r := <-done:
if r.err != nil {
t.Fatalf("SendMsg from event callback: %v", r.err)
}
if r.resp.Status != "ok" {
t.Fatalf("unexpected response: %+v", r.resp)
}
case <-time.After(5 * time.Second):
t.Fatal("deadlock: SendMsg did not return from within the event callback")
}
}
// TestEchoKey covers the response-matcher: an absent or null echo is not a
// response, and both string and non-string echoes normalize to a key.
func TestEchoKey(t *testing.T) {
cases := []struct {
in string
key string
ok bool
}{
{`"42"`, "42", true},
{`42`, "42", true},
{`null`, "", false},
{``, "", false},
}
for _, tc := range cases {
key, ok := echoKey(json.RawMessage(tc.in))
if key != tc.key || ok != tc.ok {
t.Errorf("echoKey(%q) = (%q, %v), want (%q, %v)", tc.in, key, ok, tc.key, tc.ok)
}
}
}
@@ -1,4 +1,4 @@
package qq_napcat package qq_onebot
import ( import (
"encoding/json" "encoding/json"
@@ -12,7 +12,7 @@ import (
"nukumizu-backend/postLog" "nukumizu-backend/postLog"
) )
// oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket. // oneBotEvent mirrors a OneBot 11 event pushed over the OneBot WebSocket.
// Only "message" events are handled; notice/request/meta_event are ignored. // Only "message" events are handled; notice/request/meta_event are ignored.
type oneBotEvent struct { type oneBotEvent struct {
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event" PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
@@ -29,45 +29,46 @@ type oneBotEvent struct {
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc. SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
} }
// QQController handles QQ Bot interactions by connecting directly to NapCat. // QQController handles QQ Bot interactions by connecting directly to OneBot.
type QQController struct { type QQController struct {
cfg config.QQConfig cfg config.OneBotConfig
napcatClient *Client onebotClient *Client
} }
// NewQQController creates a new QQ (Napcat) controller. // NewQQController creates a new QQ (OneBot) controller.
func NewQQController(cfg config.QQConfig) *QQController { func NewQQController(cfg config.OneBotConfig) *QQController {
q := &QQController{cfg: cfg} q := &QQController{cfg: cfg}
if cfg.Enabled { if cfg.Enabled {
q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy) q.onebotClient = NewClient(cfg.OneBotAddr, cfg.OneBotPort, cfg.OneBotToken, cfg.NetworkUseProxy)
} }
return q return q
} }
// Name returns the controller name. // Name returns the controller name.
func (q *QQController) Name() string { func (q *QQController) Name() string {
return "qq(napcat)" return "qq_onebot"
} }
// Start initializes the QQ controller and starts the NapCat WebSocket listener. // Start initializes the QQ controller and starts the OneBot WebSocket listeners
// (the event socket and the API socket the client manages internally).
func (q *QQController) Start() error { func (q *QQController) Start() error {
if !q.cfg.Enabled { if !q.cfg.Enabled {
// postLog.Info("QQ (Napcat) controller is disabled") // postLog.Info("QQ (Napcat) controller is disabled")
return nil return nil
} }
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil") postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is nil")
return nil return nil
} }
postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...") postLog.Info("[qq_onebot] QQ (OneBot) controller started, connecting to OneBot WebSocket...")
go func() { go func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r)) postLog.Error("[qq_onebot] OneBot WS listener panic recovered: " + fmt.Sprintf("%v", r))
} }
}() }()
q.napcatClient.Listen(q.handleNapcatEvent) q.onebotClient.Listen(q.handleOneBotEvent)
}() }()
return nil return nil
@@ -75,10 +76,10 @@ func (q *QQController) Start() error {
// Stop shuts down the QQ controller and its WebSocket listener. // Stop shuts down the QQ controller and its WebSocket listener.
func (q *QQController) Stop() { func (q *QQController) Stop() {
if q.napcatClient != nil { if q.onebotClient != nil {
q.napcatClient.Stop() q.onebotClient.Stop()
} }
postLog.Info("QQ (Napcat) controller stopped") postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
} }
// IsEnabled returns whether the controller is enabled. // IsEnabled returns whether the controller is enabled.
@@ -92,11 +93,11 @@ func (q *QQController) IsMarkdown() bool {
return q.cfg.Markdown return q.cfg.Markdown
} }
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket. // handleOneBotEvent processes a raw OneBot event received from the OneBot WebSocket.
func (q *QQController) handleNapcatEvent(raw []byte) { func (q *QQController) handleOneBotEvent(raw []byte) {
var ev oneBotEvent var ev oneBotEvent
if err := json.Unmarshal(raw, &ev); err != nil { if err := json.Unmarshal(raw, &ev); err != nil {
postLog.Debug("Failed to parse NapCat WS event: " + err.Error()) postLog.Debug("[qq_onebot] Failed to parse OneBot WS event: " + err.Error())
return return
} }
@@ -108,16 +109,16 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
return return
} }
debugMode := cfg.System.DebugMode debugMode := cfg.System.DebugMode
showAction := debugMode && cfg.Debug.ShowNapcatAction showAction := debugMode && cfg.Debug.ShowOneBotAction
if debugMode && cfg.Debug.ShowNapcatMsg { if debugMode && cfg.Debug.ShowOneBotMsg {
postLog.Debug("Napcat WS event received: " + string(raw)) postLog.Debug("[qq_onebot] OneBot WS event received: " + string(raw))
} }
// Only handle message events; ignore notice/request/meta_event. // Only handle message events; ignore notice/request/meta_event.
if ev.PostType != "message" { if ev.PostType != "message" {
if showAction { if showAction {
postLog.Debug("Ignoring Napcat WS event: " + string(raw)) postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
} }
return return
} }
@@ -126,9 +127,9 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
if q.isSelfMessage(ev) && !debugMode { if q.isSelfMessage(ev) && !debugMode {
return return
} }
if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg { if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
if showAction { if showAction {
postLog.Debug("Ignoring Napcat WS self message: " + string(raw)) postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
} }
return return
} }
@@ -149,19 +150,19 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
response := q.processCommand(cmd) response := q.processCommand(cmd)
if response == "" { if response == "" {
if showAction { if showAction {
postLog.Debug("Napcat WS command discarded: " + string(raw)) postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
} }
return return
} }
// Reply in the same chat. // Reply in the same chat.
if ev.MessageType == "private" { if ev.MessageType == "private" {
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat private reply: " + err.Error()) postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
} }
} else { } else {
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat group reply: " + err.Error()) postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
} }
} }
} }
@@ -179,8 +180,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
if q.cfg.ListenMethod == "at" { if q.cfg.ListenMethod == "at" {
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID) atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
if !strings.Contains(text, atMention) { if !strings.Contains(text, atMention) {
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction { if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
postLog.Debug("Napcat WS message ignored (no @mention): " + text) postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
} }
return "" // Not mentioned, ignore. return "" // Not mentioned, ignore.
} }
@@ -202,12 +203,13 @@ func (q *QQController) processCommand(cmd controller.Command) string {
// 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 cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho { if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID)) postLog.Debug(fmt.Sprintf("[qq_onebot] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
} }
if err != nil { if err != nil {
postLog.Error("Napcat command processing failed: " + err.Error()) postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
return "" return ""
} }
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
return response return response
} }
@@ -341,35 +343,35 @@ func (q *QQController) SendAlert(alert controller.Alert) error {
} }
func (q *QQController) sendGroupMessage(groupID string, message string) { func (q *QQController) sendGroupMessage(groupID string, message string) {
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("Cannot send QQ group message: NapCat client not initialized") postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
return return
} }
var groupIDInt int64 var groupIDInt int64
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 { if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID)) postLog.Warning("[qq_onebot] Invalid QQ group ID: " + groupID)
return return
} }
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err)) postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
} }
} }
func (q *QQController) sendPrivateMessage(userID string, message string) { func (q *QQController) sendPrivateMessage(userID string, message string) {
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("Cannot send QQ private message: NapCat client not initialized") postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
return return
} }
var userIDInt int64 var userIDInt int64
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 { if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID)) postLog.Warning("[qq_onebot] Invalid QQ user ID: " + userID)
return return
} }
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err)) postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
} }
} }
+5
View File
@@ -16,6 +16,11 @@ import (
) )
// WebhookController handles notifications via generic HTTP webhooks. // WebhookController handles notifications via generic HTTP webhooks.
//
// cfg and httpClient are written once, by the constructor, and never again: a
// controller that needs different settings is replaced wholesale by the manager
// rather than reconfigured in place, so the send methods can read them without
// locking.
type WebhookController struct { type WebhookController struct {
cfg config.WebhookConfig cfg config.WebhookConfig
httpClient *http.Client httpClient *http.Client
+5
View File
@@ -3,6 +3,8 @@ package controller
import ( import (
"fmt" "fmt"
"strings" "strings"
"nukumizu-backend/postLog"
) )
// Trigger validates an incoming command against the pipe's authorization // Trigger validates an incoming command against the pipe's authorization
@@ -16,9 +18,11 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
// Check whether the command arrived in a group or a private chat. // Check whether the command arrived in a group or a private chat.
if cmd.ChatType == "group" { if cmd.ChatType == "group" {
// Group commands are only honored from trusted groups, otherwise discard. // Group commands are only honored from trusted groups, otherwise discard.
// postLog.Debug(fmt.Sprintf("trustedGroups: %v", trustedGroups))
if !IsTrustedGroup(cmd.ChatID, trustedGroups) { if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
return "", nil return "", nil
} }
postLog.Debug("isTrustedGroup")
} }
// Commands that require special permission must be sent by an admin. // Commands that require special permission must be sent by an admin.
@@ -37,6 +41,7 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
if response == "" && listenMethod == "at" { if response == "" && listenMethod == "at" {
return "Unknown command: /" + cmd.Command, nil return "Unknown command: /" + cmd.Command, nil
} }
// postLog.Debug("[controller] Trigger.resp: " + response)
return response, nil return response, nil
} }
+1 -1
View File
@@ -248,7 +248,7 @@ func (c *Client) FetchNodes() ([]NodeInfo, error) {
postLog.Warning("Failed to save bot node config: " + err.Error()) postLog.Warning("Failed to save bot node config: " + err.Error())
} }
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes))) // postLog.Debug(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
return nodes, nil return nodes, nil
} }
+24 -10
View File
@@ -2,6 +2,10 @@
// network proxy configured in the system config. Each caller decides whether // network proxy configured in the system config. Each caller decides whether
// to use the proxy by passing its own useProxy flag (the per-channel // to use the proxy by passing its own useProxy flag (the per-channel
// networkUseProxy setting), so proxying is opt-in per channel. // networkUseProxy setting), so proxying is opt-in per channel.
//
// The opt-in is captured when a client is built, but the proxy address is not:
// it is read again on every request and every dial, so editing
// system.networkProxy takes effect on clients that already exist.
package netproxy package netproxy
import ( import (
@@ -39,19 +43,23 @@ func proxyURL() *url.URL {
} }
// ProxyFunc returns a transport proxy function that routes requests through // ProxyFunc returns a transport proxy function that routes requests through
// the configured network proxy when enabled. It returns nil when the caller // the configured network proxy when enabled, and nil when the caller opts out
// opts out or no proxy is configured, meaning direct connection. The returned // of proxying entirely. The returned function is compatible with both
// function is compatible with both http.Transport.Proxy and // http.Transport.Proxy and websocket.Dialer.Proxy.
// websocket.Dialer.Proxy. //
// The proxy address is resolved on every call rather than once here, so a
// settings update that changes system.networkProxy reaches a client that was
// already built. That is also why opting out is the only case that returns nil:
// a function resolved to nothing at construction time would pin its client to
// whatever was configured then. A nil URL from the returned function means no
// proxy is configured and the request goes direct.
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) { func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
if !useProxy { if !useProxy {
return nil return nil
} }
u := proxyURL() return func(*http.Request) (*url.URL, error) {
if u == nil { return proxyURL(), nil
return nil
} }
return http.ProxyURL(u)
} }
// HTTPClient builds an http.Client that sends traffic through the configured // HTTPClient builds an http.Client that sends traffic through the configured
@@ -71,10 +79,16 @@ func HTTPClient(useProxy bool, timeout time.Duration) *http.Client {
// through the configured HTTP CONNECT proxy when enabled. Its signature // through the configured HTTP CONNECT proxy when enabled. Its signature
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout // matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
// to send SMTP over the proxy. // to send SMTP over the proxy.
//
// Like ProxyFunc it reads the proxy address per dial, so clearing or changing
// system.networkProxy reaches a dialer that already exists.
func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) { func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) {
u := proxyURL()
return func(network, addr string, timeout time.Duration) (net.Conn, error) { return func(network, addr string, timeout time.Duration) (net.Conn, error) {
if !useProxy || u == nil { if !useProxy {
return net.DialTimeout(network, addr, timeout)
}
u := proxyURL()
if u == nil {
return net.DialTimeout(network, addr, timeout) return net.DialTimeout(network, addr, timeout)
} }
return dialViaProxy(u, addr, timeout) return dialViaProxy(u, addr, timeout)
+133
View File
@@ -0,0 +1,133 @@
package netproxy
import (
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"nukumizu-backend/config"
)
// publishConfig writes a config.json and makes it the configuration in effect,
// which is what a settings update does.
func publishConfig(t *testing.T, body string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
if _, err := config.LoadGlobalConfig(path); err != nil {
t.Fatalf("LoadGlobalConfig: %v", err)
}
}
// resolve runs a proxy function and returns the URL it chose, or "" when it
// chose a direct connection.
func resolve(t *testing.T, proxy func(*http.Request) (*url.URL, error)) string {
t.Helper()
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
u, err := proxy(req)
if err != nil {
t.Fatalf("proxy function: %v", err)
}
if u == nil {
return ""
}
return u.String()
}
// TestProxyFuncResolvesPerCall is the property that makes system.networkProxy
// hot-reloadable: the function handed to a transport keeps reading the live
// configuration instead of the address that was configured when it was built.
func TestProxyFuncResolvesPerCall(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
proxy := ProxyFunc(true)
if proxy == nil {
t.Fatal("ProxyFunc(true) returned nil, so the channel would never proxy")
}
if got := resolve(t, proxy); got != "http://127.0.0.1:7890" {
t.Errorf("first resolution = %q", got)
}
// The same function must follow a settings update.
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:8888"}}`)
if got := resolve(t, proxy); got != "http://127.0.0.1:8888" {
t.Errorf("after a settings update the same function resolved %q", got)
}
// Clearing the proxy falls back to a direct connection.
publishConfig(t, `{"system":{"networkProxy":""}}`)
if got := resolve(t, proxy); got != "" {
t.Errorf("a cleared proxy still resolved %q", got)
}
}
func TestProxyFuncOptOutReturnsNil(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
// A channel with networkUseProxy off must not be handed a function at all,
// so its transport keeps the default direct dialing.
if ProxyFunc(false) != nil {
t.Error("ProxyFunc(false) must return nil")
}
}
func TestProxyFuncNormalizesMissingScheme(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"127.0.0.1:7890"}}`)
if got := resolve(t, ProxyFunc(true)); got != "http://127.0.0.1:7890" {
t.Errorf("resolved %q, want the http:// prefix added", got)
}
}
func TestProxyFuncIgnoresUnusableProxy(t *testing.T) {
// A value that cannot be parsed must leave the client dialing directly
// rather than failing every request.
publishConfig(t, `{"system":{"networkProxy":"://missing-scheme"}}`)
if got := resolve(t, ProxyFunc(true)); got != "" {
t.Errorf("an unparseable proxy resolved %q, want a direct connection", got)
}
}
// TestDialWithTimeoutDialsDirectlyWithoutProxy covers the path a cleared
// system.networkProxy takes: the dialer was built while a proxy was configured,
// and must fall back to a direct dial once there is none.
func TestDialWithTimeoutDialsDirectlyWithoutProxy(t *testing.T) {
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
dial := DialWithTimeout(true)
if dial == nil {
t.Fatal("DialWithTimeout(true) returned nil")
}
// No proxy is listening on that address, so a dial attempted now would
// fail; clearing the setting is what makes the direct path reachable.
publishConfig(t, `{"system":{"networkProxy":""}}`)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err == nil {
conn.Close()
}
}()
conn, err := dial("tcp", ln.Addr().String(), 5*time.Second)
if err != nil {
t.Fatalf("dial through a cleared proxy: %v", err)
}
conn.Close()
}
+10
View File
@@ -107,6 +107,7 @@ type StatusChangeCallback func(change StatusChange)
// All methods are thread-safe. // All methods are thread-safe.
type Tracker struct { type Tracker struct {
mu sync.RWMutex mu sync.RWMutex
count int
nodes map[string]*Node // uuid → Node nodes map[string]*Node // uuid → Node
uuidToName map[string]string // uuid → name (from node list) uuidToName map[string]string // uuid → name (from node list)
onlineSet map[string]bool // which uuids are currently online onlineSet map[string]bool // which uuids are currently online
@@ -161,6 +162,13 @@ func (t *Tracker) fireCallbacks(change StatusChange) {
} }
} }
// GetNodeCount returns the total number of nodes in the tracker.
func (t *Tracker) GetNodeCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return t.count
}
// UpdateNodeList replaces the full node list and updates each node's name and // UpdateNodeList replaces the full node list and updates each node's name and
// static Info metadata. entries maps UUID → node metadata from the Komari node // static Info metadata. entries maps UUID → node metadata from the Komari node
// list. // list.
@@ -175,6 +183,8 @@ func (t *Tracker) UpdateNodeList(entries map[string]NodeListEntry) {
t.knownUUIDs[uuid] = true t.knownUUIDs[uuid] = true
} }
t.count = len(entries)
t.uuidToName = make(map[string]string, len(entries)) t.uuidToName = make(map[string]string, len(entries))
for uuid, entry := range entries { for uuid, entry := range entries {
t.uuidToName[uuid] = entry.Name t.uuidToName[uuid] = entry.Name
+44 -74
View File
@@ -14,7 +14,7 @@ import (
"nukumizu-backend/global" "nukumizu-backend/global"
"nukumizu-backend/internal/controller" "nukumizu-backend/internal/controller"
"nukumizu-backend/internal/controller/pipes" "nukumizu-backend/internal/controller/pipes"
"nukumizu-backend/internal/controller/pipes/qq_napcat" "nukumizu-backend/internal/controller/pipes/qq_onebot"
"nukumizu-backend/internal/controller/pipes/telegram" "nukumizu-backend/internal/controller/pipes/telegram"
"nukumizu-backend/internal/komari" "nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node" "nukumizu-backend/internal/node"
@@ -58,6 +58,27 @@ func main() {
postLog.SetDebugMode(cfg.System.DebugMode) postLog.SetDebugMode(cfg.System.DebugMode)
postLog.InitLogBroadcaster() postLog.InitLogBroadcaster()
// A settings update replaces the configuration in memory; these hooks push
// the new values into the state that was derived from the old one. The
// logger's debug flag is process-wide rather than read at every log call,
// and each controller holds its own copy of its channel settings plus the
// clients built from them.
config.OnReload(func(updated *config.Config) {
postLog.SetDebugMode(updated.System.DebugMode)
mgr := controller.GetManager()
if mgr == nil || !mgr.NeedsRebuild(updated.ControllerMethod) {
return
}
// Controller settings changed. Each controller reads its settings into
// fields when it is built, and the OneBot and Telegram ones own
// connections that cannot be re-pointed, so the change is applied by
// replacing the whole set rather than reconfiguring it in place.
postLog.Info("Controller settings changed; rebuilding every channel")
mgr.ReplaceAll(buildControllers(updated), updated.ControllerMethod)
})
dbPath := cfg.DBPath dbPath := cfg.DBPath
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil { if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
@@ -110,7 +131,7 @@ func main() {
initControllers() initControllers()
// Send the bot initialization message to all enabled bot controllers // Send the bot initialization message to all enabled bot controllers
// (QQ_NapCat, Telegram), then the server list right after it once the // (QQ_OneBot, Telegram), then the server list right after it once the
// initial status refresh has completed so the list reflects the real // initial status refresh has completed so the list reflects the real
// online/offline state. // online/offline state.
if mgr := controller.GetManager(); mgr != nil { if mgr := controller.GetManager(); mgr != nil {
@@ -199,83 +220,28 @@ func startWebhookServer(cfg *config.Config) {
}() }()
} }
// initControllers initializes and starts all configured controllers. // buildControllers constructs one controller per channel from the given
// configuration. The set is built fresh whenever controller settings change,
// because a controller reads its settings once at construction and two of them
// own connections that cannot be re-pointed.
func buildControllers(cfg *config.Config) []controller.Controller {
return []controller.Controller{
qq_onebot.NewQQController(cfg.ControllerMethod.QQ_OneBot),
telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
pipes.NewEmailController(cfg.ControllerMethod.Email),
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
pipes.NewWebhookController(cfg.ControllerMethod.Webhook),
}
}
// initControllers installs the initial controller set.
func initControllers() { func initControllers() {
cfg := config.Current() cfg := config.Current()
mgr := controller.GetManager() mgr := controller.GetManager()
if mgr == nil { if mgr == nil || cfg == nil {
return return
} }
mgr.ReplaceAll(buildControllers(cfg), cfg.ControllerMethod)
// QQ (Napcat) controller.
qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ)
mgr.Register(qqCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("QQ controller panic: %v", r))
}
}()
if err := qqCtrl.Start(); err != nil {
postLog.Error("Failed to start QQ controller: " + err.Error())
}
}()
// Telegram controller.
tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram)
mgr.Register(tgCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r))
}
}()
if err := tgCtrl.Start(); err != nil {
postLog.Error("Failed to start Telegram controller: " + err.Error())
}
}()
// Email controller (status-only).
emailCtrl := pipes.NewEmailController(cfg.ControllerMethod.Email)
mgr.Register(emailCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
}
}()
if err := emailCtrl.Start(); err != nil {
postLog.Error("Failed to start Email controller: " + err.Error())
}
}()
// Ntfy controller (status-only).
ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy)
mgr.Register(ntfyCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
}
}()
if err := ntfyCtrl.Start(); err != nil {
postLog.Error("Failed to start Ntfy controller: " + err.Error())
}
}()
// Webhook controller (status-only).
webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook)
mgr.Register(webhookCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
}
}()
if err := webhookCtrl.Start(); err != nil {
postLog.Error("Failed to start Webhook controller: " + err.Error())
}
}()
} }
// startBackgroundTasks starts periodic background goroutines. // startBackgroundTasks starts periodic background goroutines.
@@ -294,11 +260,15 @@ func startBackgroundTasks(_ *config.Config) {
if client == nil { if client == nil {
continue continue
} }
orgNodeCount := node.GetTracker().GetNodeCount()
nodes, err := client.FetchNodes() nodes, err := client.FetchNodes()
if err != nil { if err != nil {
postLog.Warning("Failed to refresh node list: " + err.Error()) postLog.Warning("Failed to refresh node list: " + err.Error())
continue continue
} }
if node.GetTracker().GetNodeCount() != orgNodeCount {
postLog.Info(fmt.Sprintf("Refreshed node list: %d nodes", len(nodes)))
}
tracker := node.GetTracker() tracker := node.GetTracker()
if tracker != nil { if tracker != nil {
tracker.UpdateNodeList(komari.BuildNodeListData(nodes)) tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
#
# Runs the server from source, with the build metadata stamped in.
#
# The console is embedded in the binary (web/embed.go), so compiling without
# web/dist fails. Say that plainly rather than leaving go:embed's error.
set -euo pipefail
# Run from the repository root, however the script was invoked.
cd "$(dirname "$0")"
if [ ! -f web/dist/index.html ]; then
echo "The web console is not built: web/dist is missing." >&2
echo "Run build-linux-x86_64.sh once, or \"npm run build\" in frontend/." >&2
exit 1
fi
# Get git commit hash (shortened to 7 characters, can also use full)
COMMIT=$(git rev-parse --short HEAD)
# Get UTC time
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build -ldflags
LDFLAGS="-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT"
go run -ldflags "$LDFLAGS" .