Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2159ff0c | ||
|
|
bc5cb417eb | ||
|
|
1a7a4cdc07 | ||
|
|
9137d53286 | ||
|
|
03e74f49c3 | ||
|
|
0c581abc4c | ||
|
|
ba0d8539b3 | ||
|
|
1b11be2427 | ||
|
|
72d0d587cb | ||
|
|
c5a80bd952 | ||
|
|
d06d0bcd2f | ||
|
|
aa7e7f87e1 | ||
|
|
c7642ecca5 | ||
|
|
457ea42417 | ||
|
|
7dc6089871 | ||
|
|
2a5a46a984 | ||
|
|
0691e85ccf | ||
|
|
d9a9918e21 |
No files matched your search
@@ -77,9 +77,9 @@ nukumizu-backend/
|
||||
│ ├── email.go # Email notification pipe
|
||||
│ ├── ntfy.go # ntfy notification pipe
|
||||
│ ├── webhook.go # Outgoing webhook notification pipe
|
||||
│ ├── qq_napcat/
|
||||
│ ├── qq_onebot/
|
||||
│ │ ├── 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.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
||||
@@ -101,7 +101,7 @@ nukumizu-backend/
|
||||
- Go **1.25** or newer
|
||||
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
|
||||
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||
- For QQ: 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)
|
||||
|
||||
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:
|
||||
|
||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, QQ OneBot WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
|
||||
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
|
||||
- `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
|
||||
@@ -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 |
|
||||
| `{{ 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
|
||||
|
||||
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/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/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. |
|
||||
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. Returns `data: {type, restartRequired}`, where `restartRequired` lists the keys the update changed that are only read at startup (see [What applies without a restart](#what-applies-without-a-restart)) — the write succeeds regardless, this only says which edits are not live yet. Always an array, empty when everything took effect. |
|
||||
| `/api/webhook/add` | POST | admin | Add an incoming webhook endpoint. Body `{name, enabled?, token?, notifyPipes?}` — only the fields given are stored, the rest start at their defaults. `409` when the name is already configured. |
|
||||
| `/api/webhook/modify` | POST | admin | Change an existing endpoint. Body `{name, ...}` — the fields given are the fields that change (same partial-update rule as `/api/settings/set`, but scoped to one endpoint). `404` for an unknown name, `400` when no other field is given. |
|
||||
| `/api/webhook/delete` | POST | admin | Remove an endpoint. Body `{name}`. `404` for an unknown name. |
|
||||
@@ -375,7 +387,7 @@ Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
|
||||
### Features
|
||||
- [Enhance security of websocket log system]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/da467c9297e641e2ab9a1889252589503791b7e9")
|
||||
|
||||
+33
-10
@@ -5,13 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||
// other config files this one is auto-generated and optional: a missing or
|
||||
// empty file yields an empty map, so every node falls back to its default
|
||||
// enableStatusNotify value (true).
|
||||
// empty file yields an empty map, so every node falls back to its defaults —
|
||||
// enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
|
||||
func LoadBotNodeConfig(configPath string) error {
|
||||
cfg := BotNodeMembers{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
@@ -44,6 +45,27 @@ func NodeStatusNotifyEnabled(uuid string) bool {
|
||||
return *opts.EnableStatusNotify
|
||||
}
|
||||
|
||||
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
|
||||
// hand-written value cannot overflow time.Duration.
|
||||
const maxFlapTolerantSeconds = 86400
|
||||
|
||||
// NodeFlapTolerant returns how long an Offline notification for uuid should be
|
||||
// held in order to absorb a quick reconnect, per bot_node_config.json's
|
||||
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
|
||||
// the value is zero or negative, or the config has not been loaded — in every
|
||||
// case the caller falls back to notifying immediately.
|
||||
func NodeFlapTolerant(uuid string) time.Duration {
|
||||
opts, ok := BotNodes()[uuid]
|
||||
if !ok || opts.FlapTolerant <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := opts.FlapTolerant
|
||||
if seconds > maxFlapTolerantSeconds {
|
||||
seconds = maxFlapTolerantSeconds
|
||||
}
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
|
||||
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||
// and stores it as a global singleton.
|
||||
func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
@@ -65,15 +87,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
cfg.System.ListenPort = "8080"
|
||||
}
|
||||
|
||||
// Apply defaults for QQ controller.
|
||||
if cfg.ControllerMethod.QQ.ListenMethod == "" {
|
||||
cfg.ControllerMethod.QQ.ListenMethod = "global"
|
||||
// Apply defaults for QQ_OneBot controller.
|
||||
if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
|
||||
}
|
||||
if cfg.ControllerMethod.QQ.NapcatAddr == "" {
|
||||
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1"
|
||||
if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
|
||||
}
|
||||
if cfg.ControllerMethod.QQ.NapcatPort == "" {
|
||||
cfg.ControllerMethod.QQ.NapcatPort = "3000"
|
||||
if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
|
||||
}
|
||||
|
||||
// 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
|
||||
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
||||
// 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
|
||||
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||
// its object keys emitted in sorted order by encoding/json, keeping the 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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// reloadHooks are the callbacks run after the global configuration has been
|
||||
// reloaded, i.e. after every settings update that touches config.json.
|
||||
//
|
||||
// They exist so that packages which already depend on config — the controller
|
||||
// manager, the logger — can react to an update without config importing them,
|
||||
// which would be an import cycle. Everything a hook needs is passed in.
|
||||
var (
|
||||
reloadHooksMu sync.Mutex
|
||||
reloadHooks []func(*Config)
|
||||
|
||||
// reloadRunMu serializes hook execution. A hook mutates process-wide state
|
||||
// — the logger's debug flag, the controller registry — so two overlapping
|
||||
// settings updates must not run them at the same time, or both would decide
|
||||
// to rebuild the controllers from their own view of what was applied.
|
||||
reloadRunMu sync.Mutex
|
||||
)
|
||||
|
||||
// OnReload registers a hook to run after every reload of the global
|
||||
// configuration, receiving the configuration now in effect. Hooks run in
|
||||
// registration order.
|
||||
//
|
||||
// Register once, at startup, before the first settings update can arrive: a
|
||||
// hook registered later has already missed the updates that came before it, and
|
||||
// the configuration it would have seen is not replayed.
|
||||
//
|
||||
// A hook runs on the goroutine serving /api/settings/set, so it must not block
|
||||
// for long. It may be called concurrently by two overlapping updates.
|
||||
func OnReload(hook func(*Config)) {
|
||||
reloadHooksMu.Lock()
|
||||
defer reloadHooksMu.Unlock()
|
||||
reloadHooks = append(reloadHooks, hook)
|
||||
}
|
||||
|
||||
// notifyReload runs every registered hook with cfg. A nil cfg is the signal
|
||||
// that the update touched one of the other settings files, which have no
|
||||
// hook-visible reload, and it is ignored.
|
||||
//
|
||||
// A panicking hook is logged and skipped rather than allowed to unwind through
|
||||
// UpdateSettings: by the time hooks run the new configuration is already on
|
||||
// disk and published in memory, so reporting the write as failed would be a
|
||||
// lie, and the hooks registered after the broken one must still run.
|
||||
func notifyReload(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Copy under the registry lock, then release it before running anything: a
|
||||
// hook is free to register another hook without deadlocking.
|
||||
reloadHooksMu.Lock()
|
||||
hooks := make([]func(*Config), len(reloadHooks))
|
||||
copy(hooks, reloadHooks)
|
||||
reloadHooksMu.Unlock()
|
||||
|
||||
reloadRunMu.Lock()
|
||||
defer reloadRunMu.Unlock()
|
||||
for _, hook := range hooks {
|
||||
runReloadHook(hook, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// runReloadHook runs one hook, isolating a panic to that hook. Recovering in a
|
||||
// separate function rather than inline is deliberate: a deferred recover placed
|
||||
// in the loop body would not run until notifyReload itself returned, which
|
||||
// would abandon the remaining hooks.
|
||||
func runReloadHook(hook func(*Config), cfg *Config) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Configuration reload hook panicked: %v", r))
|
||||
}
|
||||
}()
|
||||
hook(cfg)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// swapReloadHooks replaces the registered reload hooks for the duration of a
|
||||
// test and restores the previous set afterwards, so the package-level registry
|
||||
// cannot leak into another test.
|
||||
func swapReloadHooks(t *testing.T, hooks ...func(*Config)) {
|
||||
t.Helper()
|
||||
|
||||
reloadHooksMu.Lock()
|
||||
original := reloadHooks
|
||||
reloadHooks = hooks
|
||||
reloadHooksMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
reloadHooksMu.Lock()
|
||||
reloadHooks = original
|
||||
reloadHooksMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestReloadHookRunsOnlyForGlobalSettings(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{}`)
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{}`)
|
||||
|
||||
var seen []bool
|
||||
swapReloadHooks(t, func(cfg *Config) {
|
||||
seen = append(seen, cfg.System.DebugMode)
|
||||
})
|
||||
|
||||
// A config.json update runs the hook, with the values just written.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(global): %v", err)
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("hook ran %d times for a config.json update, want 1", len(seen))
|
||||
}
|
||||
if !seen[0] {
|
||||
t.Error("hook received a configuration without the updated debugMode")
|
||||
}
|
||||
|
||||
// The other two files reload a singleton that callers read at the point of
|
||||
// use, so they have nothing to notify.
|
||||
for _, settingsType := range []string{SettingBotUserConfig, SettingBotNodeConfig} {
|
||||
if err := UpdateSettings(settingsType, map[string]interface{}{
|
||||
"unused": map[string]interface{}{"event_reply": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings(%s): %v", settingsType, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) != 1 {
|
||||
t.Errorf("hook ran %d times after updates to the other settings files, want 1", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadHookIsolatesPanic(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
|
||||
reached := false
|
||||
swapReloadHooks(t,
|
||||
func(*Config) { panic("hook under test") },
|
||||
func(*Config) { reached = true },
|
||||
)
|
||||
|
||||
// The configuration is already on disk and published by the time hooks run,
|
||||
// so a broken hook must not turn a successful write into a failed request.
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"system": map[string]interface{}{"debugMode": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("a panicking hook must not fail the settings write: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Error("a panicking hook stopped the hooks registered after it")
|
||||
}
|
||||
|
||||
// The write itself must still have landed.
|
||||
cfg := Current()
|
||||
if cfg == nil || !cfg.System.DebugMode {
|
||||
t.Error("the settings write did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnrelatedUpdateLeavesControllerMethodAlone guards the trigger for a
|
||||
// controller rebuild. Whether to rebuild is decided by comparing the whole
|
||||
// controllerMethod section with the one the running controllers were built
|
||||
// from, so reloading the file has to reproduce that section byte for byte. A
|
||||
// default applied inconsistently — a nil recipient slice turned into an empty
|
||||
// one on the second load, say — would make every settings edit look like a
|
||||
// controller change and tear down every channel on each save.
|
||||
func TestUnrelatedUpdateLeavesControllerMethodAlone(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"controllerMethod": {
|
||||
"qq(napcat)": { "enabled": false },
|
||||
"email": { "enabled": false, "to": [] },
|
||||
"webhook": { "enabled": false, "headers": {} }
|
||||
}
|
||||
}`)
|
||||
|
||||
first, err := LoadGlobalConfig(global.ConfigPath.Global)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadGlobalConfig: %v", err)
|
||||
}
|
||||
before := first.ControllerMethod
|
||||
|
||||
if err := UpdateSettings(SettingGlobal, map[string]interface{}{
|
||||
"controllerMessage": map[string]interface{}{"BOT_STARTED": "hello"},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
after := Current().ControllerMethod
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Errorf("an unrelated update changed the controllerMethod section:\nbefore: %+v\nafter: %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadHookSeesEveryWriterPath pins the invariant that each writer of
|
||||
// config.json notifies, not just /api/settings/set: the webhook endpoint
|
||||
// helpers go through the same channel.
|
||||
func TestReloadHookSeesEveryWriterPath(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.Global, `{
|
||||
"webhook": { "endpoints": {} }
|
||||
}`)
|
||||
|
||||
var seen int
|
||||
swapReloadHooks(t, func(*Config) { seen++ })
|
||||
|
||||
if err := AddWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "secret",
|
||||
"notifyPipes": []interface{}{"ntfy"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Errorf("hook ran %d times after adding an endpoint, want 1", seen)
|
||||
}
|
||||
|
||||
if err := ModifyWebhookEndpoint("example", map[string]interface{}{
|
||||
"enabled": false,
|
||||
}); err != nil {
|
||||
t.Fatalf("ModifyWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("hook ran %d times after modifying an endpoint, want 2", seen)
|
||||
}
|
||||
|
||||
if err := DeleteWebhookEndpoint("example"); err != nil {
|
||||
t.Fatalf("DeleteWebhookEndpoint: %v", err)
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran %d times after deleting an endpoint, want 3", seen)
|
||||
}
|
||||
|
||||
// A rejected write changes nothing, so it must not notify either.
|
||||
if err := DeleteWebhookEndpoint("ghost"); err == nil {
|
||||
t.Error("deleting an unknown endpoint should fail")
|
||||
}
|
||||
if seen != 3 {
|
||||
t.Errorf("hook ran for a rejected write (%d notifications, want 3)", seen)
|
||||
}
|
||||
}
|
||||
+127
-16
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
@@ -23,6 +24,89 @@ const (
|
||||
// accepted constants above.
|
||||
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
|
||||
|
||||
// startupOnlySettings are the config.json keys that are read once before the
|
||||
// program starts serving and never again: the listener addresses, the paths the
|
||||
// databases are opened from, and the Komari dashboard its client is built
|
||||
// against. Editing one writes the file and replaces the in-memory
|
||||
// configuration, but the running program keeps the old value, so an update that
|
||||
// touches one is reported back to the caller instead of being silently
|
||||
// accepted.
|
||||
//
|
||||
// Keep this in step with main: these are exactly the settings main reads before
|
||||
// the HTTP server comes up. Everything else — controllerMethod, networkProxy,
|
||||
// the message templates, the debug switches — is picked up at runtime.
|
||||
var startupOnlySettings = []string{
|
||||
"system.listenAddr",
|
||||
"system.listenPort",
|
||||
"webhook.enabled",
|
||||
"webhook.listenAddr",
|
||||
"webhook.listenPort",
|
||||
"komari.dashboardURL",
|
||||
"dataPath",
|
||||
"dbPath",
|
||||
}
|
||||
|
||||
// IsRestartRequiredKeys lists the settings in patch that only take effect at
|
||||
// startup, as dot-separated paths, in the order startupOnlySettings declares
|
||||
// them. Only config.json carries such settings; an update to one of the other
|
||||
// files always reports nothing.
|
||||
//
|
||||
// The write itself succeeds either way — this is advice for the user, not a
|
||||
// rejection. The result is never nil, so a caller can put it straight into a
|
||||
// JSON response and get [] rather than null.
|
||||
func IsRestartRequiredKeys(settingsType string, patch map[string]interface{}) []string {
|
||||
keys := []string{}
|
||||
if settingsType != SettingGlobal {
|
||||
return keys
|
||||
}
|
||||
|
||||
patched := patchPaths(patch)
|
||||
for _, watched := range startupOnlySettings {
|
||||
for _, path := range patched {
|
||||
if isPathsOverlap(path, watched) {
|
||||
keys = append(keys, watched)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// patchPaths expands a nested settings patch into the dot-separated paths of its
|
||||
// leaves. An object is descended into rather than reported, so a patch that only
|
||||
// names sections still resolves to the keys it changes, and a JSON null is a
|
||||
// leaf because it deletes the key it names.
|
||||
func patchPaths(patch map[string]interface{}) []string {
|
||||
paths := []string{}
|
||||
var walk func(prefix string, node map[string]interface{})
|
||||
walk = func(prefix string, node map[string]interface{}) {
|
||||
for key, value := range node {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if nested, ok := value.(map[string]interface{}); ok && nested != nil {
|
||||
walk(path, nested)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
walk("", patch)
|
||||
return paths
|
||||
}
|
||||
|
||||
// isPathsOverlap reports whether a patched path and a watched setting can affect
|
||||
// each other: they are the same key, the patch names something inside the
|
||||
// watched setting, or the patch names a section the watched setting lives in.
|
||||
// The last case matters because a patch may replace a whole section, which
|
||||
// changes every key under it.
|
||||
func isPathsOverlap(patched, watched string) bool {
|
||||
return patched == watched ||
|
||||
strings.HasPrefix(patched, watched+".") ||
|
||||
strings.HasPrefix(watched, patched+".")
|
||||
}
|
||||
|
||||
// settingsLock serializes read-modify-write access to the on-disk configuration
|
||||
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
||||
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
||||
@@ -82,19 +166,45 @@ func GetSettings(settingsType string) ([]byte, error) {
|
||||
// written the matching in-memory singleton is reloaded so runtime code observes
|
||||
// the new values.
|
||||
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
return updateSettingsLocked(settingsType, patch)
|
||||
})
|
||||
}
|
||||
|
||||
return updateSettingsLocked(settingsType, patch)
|
||||
// runSettingsUpdate runs fn under settingsLock and then, once the lock is
|
||||
// released, runs the reload hooks with whatever configuration fn reports (nil
|
||||
// when the update did not touch config.json).
|
||||
//
|
||||
// The hooks deliberately run outside settingsLock. A hook rebuilds controllers,
|
||||
// which can wait on a network call, while settingsLock is also held by the node
|
||||
// tracker's background save (SaveBotNodeConfig); holding it across a hook would
|
||||
// stall node registration behind an unrelated settings edit.
|
||||
func runSettingsUpdate(fn func() (*Config, error)) error {
|
||||
cfg, err := func() (*Config, error) {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
return fn()
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyReload(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateSettingsLocked is UpdateSettings without the locking, for callers that
|
||||
// need to inspect the loaded configuration and write in one critical section
|
||||
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, ¤t); err != nil {
|
||||
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||
return nil, fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
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)
|
||||
|
||||
data, err = json.MarshalIndent(current, "", " ")
|
||||
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')
|
||||
|
||||
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)
|
||||
@@ -151,18 +261,19 @@ func deepMergeSettings(dst, src map[string]interface{}) {
|
||||
}
|
||||
|
||||
// reloadSettings refreshes the in-memory singleton for the given settings type
|
||||
// so the running program observes the values just persisted to disk.
|
||||
func reloadSettings(settingsType, path string) error {
|
||||
// so the running program observes the values just persisted to disk. Only
|
||||
// config.json has a hook-visible reload, so for SettingGlobal it returns the
|
||||
// configuration now in effect and for the other types it returns nil.
|
||||
func reloadSettings(settingsType, path string) (*Config, error) {
|
||||
switch settingsType {
|
||||
case SettingGlobal:
|
||||
_, err := LoadGlobalConfig(path)
|
||||
return err
|
||||
return LoadGlobalConfig(path)
|
||||
case SettingBotUserConfig:
|
||||
_, err := LoadBotUserConfig(path)
|
||||
return err
|
||||
return nil, err
|
||||
case SettingBotNodeConfig:
|
||||
return LoadBotNodeConfig(path)
|
||||
return nil, LoadBotNodeConfig(path)
|
||||
default:
|
||||
return ErrUnsupportedSettingsType
|
||||
return nil, ErrUnsupportedSettingsType
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"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) {
|
||||
for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} {
|
||||
if !IsValidSettingsType(valid) {
|
||||
|
||||
+32
-20
@@ -15,12 +15,13 @@ type SystemConfig struct {
|
||||
|
||||
// DebugConfig holds debug-level configuration.
|
||||
type DebugConfig struct {
|
||||
ShowNapcatMsg bool `json:"showNapcatMsg"`
|
||||
ShowNapcatAction bool `json:"showNapcatAction"`
|
||||
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
||||
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
||||
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
||||
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"`
|
||||
ShowOneBotMsg bool `json:"showOneBotMsg"`
|
||||
ShowOneBotAction bool `json:"showOneBotAction"`
|
||||
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
||||
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
||||
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
||||
OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
|
||||
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
|
||||
}
|
||||
|
||||
// KomariAccount holds Komari login credentials.
|
||||
@@ -35,14 +36,14 @@ type KomariConfig struct {
|
||||
Account KomariAccount `json:"account"`
|
||||
}
|
||||
|
||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
||||
type QQConfig struct {
|
||||
// QQConfig holds QQ (OneBot) Bot controller configuration.
|
||||
type OneBotConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
NapcatAddr string `json:"napcatAddr"`
|
||||
NapcatPort string `json:"napcatPort"`
|
||||
NapcatToken string `json:"napcatToken"`
|
||||
OneBotAddr string `json:"oneBotAddr"`
|
||||
OneBotPort string `json:"oneBotPort"`
|
||||
OneBotToken string `json:"oneBotToken"`
|
||||
BotQQID int64 `json:"botQQID"`
|
||||
ListenMethod string `json:"listenMethod"`
|
||||
}
|
||||
@@ -96,11 +97,11 @@ type WebhookConfig struct {
|
||||
|
||||
// ControllerMethodConfig holds all controller method configurations.
|
||||
type ControllerMethodConfig struct {
|
||||
QQ QQConfig `json:"qq(napcat)"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Ntfy NtfyConfig `json:"ntfy"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
QQ_OneBot OneBotConfig `json:"qq_onebot"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Ntfy NtfyConfig `json:"ntfy"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
}
|
||||
|
||||
// WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are
|
||||
@@ -119,7 +120,7 @@ type WebhookEndpointConfig struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
// 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").
|
||||
NotifyPipes []string `json:"notifyPipes"`
|
||||
}
|
||||
@@ -216,7 +217,7 @@ func (m BotUserMembers) IDs() []string {
|
||||
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 {
|
||||
Admins BotUserMembers `json:"admins"`
|
||||
TrustedGroups BotUserMembers `json:"trustedGroups"`
|
||||
@@ -232,7 +233,7 @@ type BotUser_TelegramConfig struct {
|
||||
// and trusted groups (chat targets) for each bot channel separately from the
|
||||
// main config.json.
|
||||
type BotUserConfig struct {
|
||||
QQ BotUser_QQConfig `json:"qq(napcat)"`
|
||||
QQ BotUser_QQConfig `json:"qq_onebot"`
|
||||
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
|
||||
// omitempty keeps untouched nodes stored as {}.
|
||||
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
||||
|
||||
// FlapTolerant is the number of seconds a node's Offline notification is
|
||||
// held in the hope the node returns. A node that comes back online within
|
||||
// this window produces no notification at all; one still offline when the
|
||||
// window elapses reports Offline as usual. Zero (or an absent field)
|
||||
// disables inhibition, which keeps the notify-immediately behavior. It is
|
||||
// a float64 rather than an int so a hand-written fractional value such as
|
||||
// 2.5 parses instead of failing the whole config load at startup, and it
|
||||
// is not a pointer because 0 already means "off", unlike
|
||||
// EnableStatusNotify whose default is true.
|
||||
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
|
||||
}
|
||||
|
||||
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||
// options.
|
||||
type BotNodeMembers map[string]BotNodeOptions
|
||||
|
||||
// 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
|
||||
// swapped atomically; read it through BotNodes.
|
||||
var botNodeConfig atomic.Pointer[BotNodeMembers]
|
||||
|
||||
+18
-22
@@ -60,13 +60,12 @@ func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
})
|
||||
}
|
||||
|
||||
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
||||
@@ -84,27 +83,24 @@ func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
||||
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
||||
// name. The endpoint stops accepting requests as soon as the configuration is
|
||||
// reloaded.
|
||||
func DeleteWebhookEndpoint(name string) error {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
||||
return runSettingsUpdate(func() (*Config, error) {
|
||||
if _, exists := webhookEndpoint(name); !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
||||
}
|
||||
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
||||
})
|
||||
}
|
||||
|
||||
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
|
||||
## Features
|
||||
|
||||
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
|
||||
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, 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`.
|
||||
- **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.
|
||||
|
||||
Generated
-3
@@ -1473,7 +1473,6 @@
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1596,7 +1595,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -1671,7 +1669,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
|
||||
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.42",
|
||||
"@vue/compiler-sfc": "3.5.42",
|
||||
|
||||
@@ -13,6 +13,10 @@ export const serverApi = {
|
||||
|
||||
// /api/settings/get?type=… / /api/settings/set?type=…
|
||||
// get → { success, message, data: { config } }.
|
||||
// set → { success, message, data: { type, restartRequired } }, where
|
||||
// restartRequired lists the keys the update changed that are only read at
|
||||
// startup, so the caller can say which edits are not live yet. It is
|
||||
// always an array, empty when the whole update took effect.
|
||||
// `type` is one of global | bot_user_config | bot_node_config.
|
||||
// For set, pass a partial object; a JSON null value removes that key.
|
||||
export const settingsApi = {
|
||||
|
||||
@@ -133,8 +133,15 @@ async function save() {
|
||||
const patch = wrapRoot(props.section, nest(obj));
|
||||
saving.value = true;
|
||||
try {
|
||||
await settingsApi.set('global', patch);
|
||||
toast.success(`${props.section.title} saved`);
|
||||
const res = await settingsApi.set('global', patch);
|
||||
// The backend reports the keys it wrote that are only read at startup.
|
||||
// A plain "saved" would suggest those are live too.
|
||||
const pending = (res && res.data && res.data.restartRequired) || [];
|
||||
if (pending.length) {
|
||||
toast.warn(`${props.section.title} saved — restart to apply: ${pending.join(', ')}`, 7000);
|
||||
} else {
|
||||
toast.success(`${props.section.title} saved`);
|
||||
}
|
||||
emit('saved');
|
||||
} catch (e) {
|
||||
toast.error('Failed to save: ' + e.message);
|
||||
|
||||
+156
-20
@@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
function percent(m) {
|
||||
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
||||
@@ -13,9 +14,20 @@ const query = ref('');
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const entries = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
let timer = null;
|
||||
|
||||
// The per-node options dialog. Its form is a copy of the node's settings taken
|
||||
// when the dialog opens, so the 15s card refresh behind it cannot disturb an
|
||||
// open editor, and closing without saving leaves the node untouched.
|
||||
const editing = reactive({
|
||||
open: false,
|
||||
uuid: '',
|
||||
name: '',
|
||||
notify: true,
|
||||
flap: '',
|
||||
saving: false
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return entries.value;
|
||||
@@ -57,7 +69,8 @@ async function load() {
|
||||
online: !!s.online,
|
||||
report: s.report || 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));
|
||||
@@ -71,21 +84,42 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(entry) {
|
||||
const next = !entry.notify;
|
||||
const prev = entry.notify;
|
||||
entry.notify = next;
|
||||
saving.add(entry.uuid);
|
||||
function openOptions(entry) {
|
||||
editing.uuid = entry.uuid;
|
||||
editing.name = entry.name;
|
||||
editing.notify = entry.notify;
|
||||
editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : '';
|
||||
editing.open = true;
|
||||
}
|
||||
|
||||
// saveOptions writes both options in one patch. A flap window that is not a
|
||||
// positive number disables inhibition, which is sent as a JSON null so the key
|
||||
// is removed rather than stored as an explicit 0 — the file then keeps matching
|
||||
// its documented "absent means off" default.
|
||||
async function saveOptions() {
|
||||
const typed = Number(editing.flap);
|
||||
const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0;
|
||||
const uuid = editing.uuid;
|
||||
|
||||
editing.saving = true;
|
||||
try {
|
||||
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);
|
||||
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) {
|
||||
entry.notify = prev;
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
saving.delete(entry.uuid);
|
||||
editing.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +145,6 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>Nodes</h1>
|
||||
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
||||
@@ -207,17 +240,60 @@ onBeforeUnmount(() => {
|
||||
<span>·</span>
|
||||
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
||||
</div>
|
||||
<div class="notify">
|
||||
<Toggle
|
||||
:model-value="e.notify"
|
||||
label="Status notify"
|
||||
:disabled="saving.has(e.uuid)"
|
||||
@update:model-value="toggleNotify(e)"
|
||||
/>
|
||||
<div class="opts">
|
||||
<span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
|
||||
<i class="fas fa-bell-slash" /> Notify off
|
||||
</span>
|
||||
<span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
|
||||
<i class="fas fa-shield-halved" /> Flap {{ e.flapTolerant }}s
|
||||
</span>
|
||||
<button class="icon-btn" title="Node options" @click="openOptions(e)">
|
||||
<i class="fas fa-pen" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<Modal :open="editing.open" title="Node options" @close="editing.open = false">
|
||||
<div class="node-head">
|
||||
<p class="node-name">{{ editing.name }}</p>
|
||||
<p class="node-uuid mono">{{ editing.uuid }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Status notify</label>
|
||||
<Toggle v-model="editing.notify" label="Report online / offline changes" />
|
||||
<p class="help">With this off, the node's status changes are not delivered to any pipes.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Flap inhibition</label>
|
||||
<div class="flap-row">
|
||||
<input
|
||||
v-model="editing.flap"
|
||||
class="input flap-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="86400"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
@keydown.enter="saveOptions"
|
||||
/>
|
||||
<span class="flap-unit">seconds</span>
|
||||
</div>
|
||||
<p class="help">How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.</p>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="editing.open = false">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="editing.saving" @click="saveOptions">
|
||||
<span v-if="editing.saving" class="spinner" style="width:12px;height:12px" />
|
||||
<i v-else class="fas fa-check" /> Save
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -298,7 +374,67 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,7 @@ const sections = [
|
||||
{
|
||||
id: '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'],
|
||||
fields: [
|
||||
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
|
||||
@@ -23,21 +23,22 @@ const sections = [
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug logging',
|
||||
hint: 'Per-module verbosity for the bot pipes.',
|
||||
hint: '',
|
||||
root: ['debug'],
|
||||
fields: [
|
||||
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
|
||||
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
|
||||
{ key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' },
|
||||
{ key: 'showOneBotAction', type: 'bool', label: 'OneBot actions' },
|
||||
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
||||
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
||||
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
||||
{ key: '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',
|
||||
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'],
|
||||
fields: [
|
||||
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
|
||||
@@ -60,16 +61,16 @@ const sections = [
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
title: 'QQ controller (NapCat)',
|
||||
root: ['controllerMethod', 'qq(napcat)'],
|
||||
id: 'qq_onebot',
|
||||
title: 'QQ controller (OneBot)',
|
||||
root: ['controllerMethod', 'qq_onebot'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
||||
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
|
||||
{ key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
|
||||
{ key: 'oneBotPort', type: 'text', label: 'OneBot port' },
|
||||
{ key: 'oneBotToken', type: 'password', label: 'OneBot token' },
|
||||
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
@@ -80,7 +81,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'telegram'],
|
||||
fields: [
|
||||
{ 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: 'botToken', type: 'password', label: 'Bot token' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
@@ -92,7 +93,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'email'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
||||
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
||||
@@ -109,7 +110,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'ntfy'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'server', type: 'text', label: 'Server' },
|
||||
{ key: 'topic', type: 'text', label: 'Topic' },
|
||||
@@ -124,7 +125,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'webhook'],
|
||||
fields: [
|
||||
{ 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: 'url', type: 'text', label: 'URL' },
|
||||
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||
@@ -170,7 +171,6 @@ onMounted(load);
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>System settings</h1>
|
||||
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Modal from '../components/Modal.vue';
|
||||
// controller names in config.json (controllerMethod.*), which is exactly what
|
||||
// the backend matches notifyPipes against.
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ' },
|
||||
{ key: 'qq_onebot', label: 'QQ_OneBot' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'ntfy', label: 'ntfy' },
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
|
||||
|
||||
var SoftwareInfo = SoftwareInfoStr{
|
||||
Name: "Nukumizu",
|
||||
Version: "0.2.0",
|
||||
Version: "0.2.2",
|
||||
Developer: "Madobi Nanami",
|
||||
BuildVer: 5,
|
||||
BuildVer: 7,
|
||||
CommitHash: "unknown",
|
||||
Description: "Remote server monitoring and command execution subsystem for Komari",
|
||||
BuildType: "pre-release",
|
||||
|
||||
+8
-1
@@ -47,6 +47,10 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// {"system": {"debugMode": true}}
|
||||
//
|
||||
// Multiple entries may be given at once; only the provided keys are changed.
|
||||
//
|
||||
// The response carries data.restartRequired: the keys the update changed that
|
||||
// are only read at startup, so the caller can say which edits are not live yet.
|
||||
// It is empty for an update that took effect in full.
|
||||
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "POST", "admin") {
|
||||
return
|
||||
@@ -80,7 +84,10 @@ func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The write landed either way. This only tells the caller which of the keys
|
||||
// it changed will not be live until the program is restarted.
|
||||
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
|
||||
"type": settingsType,
|
||||
"type": settingsType,
|
||||
"restartRequired": config.IsRestartRequiredKeys(settingsType, patch),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// tempConfigFile points one of the settings paths at a throwaway file, so a
|
||||
// test can drive the settings API without touching the config files in the
|
||||
// working directory.
|
||||
func tempConfigFile(t *testing.T, field *string, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
original := *field
|
||||
*field = path
|
||||
t.Cleanup(func() { *field = original })
|
||||
}
|
||||
|
||||
// settingsSetRequest builds an authenticated POST for the settings endpoint.
|
||||
func settingsSetRequest(t *testing.T, settingsType, body string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/settings/set?type="+settingsType,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("X-Token", "test-admin-token")
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// settingsSetResponse is the envelope /api/settings/set answers with.
|
||||
type settingsSetResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Type string `json:"type"`
|
||||
RestartRequired []string `json:"restartRequired"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeSettingsSetResponse(t *testing.T, w *httptest.ResponseRecorder) settingsSetResponse {
|
||||
t.Helper()
|
||||
var body settingsSetResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestSettingsSetReportsStartupOnlyKeys covers the field the console reads to
|
||||
// tell the user which of their edits are not live yet.
|
||||
func TestSettingsSetReportsStartupOnlyKeys(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false,"listenPort":"8080"}}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "global",
|
||||
`{"system":{"listenPort":"9090","debugMode":true}}`))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeSettingsSetResponse(t, w)
|
||||
if !body.Success {
|
||||
t.Fatalf("not a success envelope: %s", w.Body.String())
|
||||
}
|
||||
if body.Data.Type != "global" {
|
||||
t.Errorf("data.type = %q, want global", body.Data.Type)
|
||||
}
|
||||
if len(body.Data.RestartRequired) != 1 || body.Data.RestartRequired[0] != "system.listenPort" {
|
||||
t.Errorf("restartRequired = %v, want [system.listenPort]", body.Data.RestartRequired)
|
||||
}
|
||||
|
||||
// Being reported as startup-only must not stop the write.
|
||||
data, err := config.GetSettings(config.SettingGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"listenPort": "9090"`, `"debugMode": true`} {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Errorf("the patch was not applied, missing %s:\n%s", want, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSetRestartRequiredIsAlwaysAnArray pins the shape a client
|
||||
// iterates over: an update with nothing to report must answer [] and not null.
|
||||
func TestSettingsSetRestartRequiredIsAlwaysAnArray(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{"system":{"debugMode":false}}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "global", `{"system":{"debugMode":true}}`))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"restartRequired":[]`) {
|
||||
t.Errorf("restartRequired should serialize as an empty array: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSetRejectsUnknownType keeps the failure path intact now that the
|
||||
// success path computes an extra field.
|
||||
func TestSettingsSetRejectsUnknownType(t *testing.T) {
|
||||
setupAdminToken()
|
||||
tempConfigFile(t, &global.ConfigPath.Global, `{}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
SettingsSetHandler(w, settingsSetRequest(t, "nonsense", `{}`))
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package controller
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
@@ -25,7 +28,7 @@ type Command struct {
|
||||
|
||||
// Message represents a message to be sent by a controller.
|
||||
type Message struct {
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "onebot")
|
||||
Content string // The message content
|
||||
ChatID int64 // Chat/group ID where the message should be sent
|
||||
Type string // Message type (see MessageType*), used for per-member opt-outs
|
||||
@@ -98,17 +101,45 @@ type Controller interface {
|
||||
|
||||
// BotController is implemented by controllers that act as chat bots and can
|
||||
// deliver arbitrary messages, such as the bot initialization message on startup.
|
||||
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only
|
||||
// Only bot-type pipes (QQ_OneBot, Telegram) implement it; notification-only
|
||||
// pipes (email, ntfy, webhook) do not.
|
||||
type BotController interface {
|
||||
Controller
|
||||
SendMessage(message Message) error
|
||||
}
|
||||
|
||||
// flapPending is an Offline notification being held back for a node's
|
||||
// flap_tolerant window, in the hope that the node returns before the deadline.
|
||||
// The held change is kept by value so a later mutation of the caller's copy
|
||||
// cannot reach it.
|
||||
type flapPending struct {
|
||||
timer *time.Timer
|
||||
change node.StatusChange
|
||||
}
|
||||
|
||||
// Manager manages all controller instances and routes events.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
controllers map[string]Controller
|
||||
|
||||
// builtFrom records the controllerMethod section the registered controllers
|
||||
// were built from, so a settings update that concerns them can be told apart
|
||||
// from one that does not. It is read on the settings-update goroutine and
|
||||
// written when the set is replaced.
|
||||
builtFrom atomic.Pointer[config.ControllerMethodConfig]
|
||||
|
||||
// flapMu guards flapPending, the Offline notifications currently held for
|
||||
// flap inhibition, keyed by node UUID. It is a separate lock from mu and is
|
||||
// never held while mu is taken: a held notification is dispatched by a timer
|
||||
// goroutine, which must not block behind a routing swap, and dispatching
|
||||
// (network I/O per controller) must not happen while holding the flap lock.
|
||||
flapMu sync.Mutex
|
||||
flapPending map[string]*flapPending
|
||||
|
||||
// flapToleranceFor overrides the per-node flap window. It is nil in
|
||||
// production, where the value comes from bot_node_config.json through
|
||||
// config.NodeFlapTolerant; tests set it to keep their timings hermetic.
|
||||
flapToleranceFor func(uuid string) time.Duration
|
||||
}
|
||||
|
||||
var globalManager *Manager
|
||||
@@ -117,6 +148,7 @@ var globalManager *Manager
|
||||
func InitManager() {
|
||||
globalManager = &Manager{
|
||||
controllers: make(map[string]Controller),
|
||||
flapPending: make(map[string]*flapPending),
|
||||
}
|
||||
postLog.Info("Controller manager initialized")
|
||||
}
|
||||
@@ -126,16 +158,74 @@ func GetManager() *Manager {
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// Register adds a controller to the manager.
|
||||
func (m *Manager) Register(c Controller) {
|
||||
// NeedsRebuild reports whether next differs from the controllerMethod section
|
||||
// the registered controllers were built from. A manager with no controllers yet
|
||||
// always reports true, so the first call installs the initial set.
|
||||
//
|
||||
// Controllers are rebuilt wholesale rather than reconfigured in place: each one
|
||||
// reads its settings into fields at construction, and two of them own
|
||||
// connections that cannot be re-pointed (the OneBot WebSocket listener is
|
||||
// stopped through a sync.Once, the Telegram polling context is created with the
|
||||
// controller). Replacing the set keeps every channel on the same footing.
|
||||
func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool {
|
||||
built := m.builtFrom.Load()
|
||||
if built == nil {
|
||||
return true
|
||||
}
|
||||
// The section carries a header map and a recipient slice, so it is not
|
||||
// comparable with ==.
|
||||
return !reflect.DeepEqual(*built, next)
|
||||
}
|
||||
|
||||
// ReplaceAll stops every registered controller and swaps in next, which the
|
||||
// caller built from method. It is the only way controllers are installed, at
|
||||
// startup and after a settings change alike.
|
||||
//
|
||||
// The swap happens under the registry lock so routing flips to the new set
|
||||
// atomically; stopping and starting happen outside it. Both can block — Stop
|
||||
// closes sockets, Start performs a handshake — and holding m.mu across them
|
||||
// would stall every notification for the duration.
|
||||
func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.controllers[c.Name()] = c
|
||||
postLog.Info("Controller registered: " + c.Name())
|
||||
previous := m.controllers
|
||||
m.controllers = make(map[string]Controller, len(next))
|
||||
for _, ctrl := range next {
|
||||
m.controllers[ctrl.Name()] = ctrl
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.builtFrom.Store(&method)
|
||||
|
||||
names := make([]string, 0, len(next))
|
||||
for _, ctrl := range next {
|
||||
names = append(names, ctrl.Name())
|
||||
}
|
||||
postLog.Info("Controller set installed: " + strings.Join(names, ", "))
|
||||
|
||||
for _, ctrl := range previous {
|
||||
ctrl.Stop()
|
||||
}
|
||||
|
||||
// Start off the calling goroutine, the way startup does: Telegram's getMe
|
||||
// and the OneBot WebSocket handshake would otherwise hold the settings
|
||||
// request open for as long as they take. The new controllers are already
|
||||
// routable, and each one can send before Start returns.
|
||||
for _, ctrl := range next {
|
||||
go func(ctrl Controller) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Controller %s panicked on start: %v", ctrl.Name(), r))
|
||||
}
|
||||
}()
|
||||
if err := ctrl.Start(); err != nil {
|
||||
postLog.Error(fmt.Sprintf("Failed to start controller %s: %v", ctrl.Name(), err))
|
||||
}
|
||||
}(ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
||||
// 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
|
||||
// node whose enableStatusNotify is not true is skipped entirely, so no
|
||||
// controller (chat bots or notification pipes) broadcasts its change.
|
||||
//
|
||||
// It also applies flap inhibition, the counterpart of the allow-list and read
|
||||
// from the same file: an Offline notification for a node with a positive
|
||||
// flap_tolerant is held back for that many seconds instead of being broadcast
|
||||
// (see holdOffline). Because both gates live here, every controller is subject
|
||||
// to them, chat bots and notification pipes alike.
|
||||
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
||||
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
||||
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
||||
return
|
||||
}
|
||||
|
||||
switch change.Event {
|
||||
case "Offline":
|
||||
if tolerance := m.flapTolerance(change.UUID); tolerance > 0 {
|
||||
m.holdOffline(change, tolerance)
|
||||
return
|
||||
}
|
||||
case "Online":
|
||||
// A node that returns while its Offline is still held flaps: both
|
||||
// halves of the pair are suppressed, and nothing is sent at all.
|
||||
if m.releaseHeldOffline(change.UUID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.dispatchStatusChange(change)
|
||||
}
|
||||
|
||||
// dispatchStatusChange hands a change to every enabled controller.
|
||||
func (m *Manager) dispatchStatusChange(change node.StatusChange) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -274,8 +499,19 @@ func (m *Manager) IsMarkdown(pipeName string) bool {
|
||||
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() {
|
||||
m.flapMu.Lock()
|
||||
for uuid, held := range m.flapPending {
|
||||
held.timer.Stop()
|
||||
delete(m.flapPending, uuid)
|
||||
}
|
||||
m.flapMu.Unlock()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, ctrl := range m.controllers {
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
)
|
||||
|
||||
// fakeController records the lifecycle calls it receives so a test can assert
|
||||
// what ReplaceAll did to it. Start signals on started, because ReplaceAll starts
|
||||
// controllers off the calling goroutine.
|
||||
type fakeController struct {
|
||||
name string
|
||||
started chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
starts int
|
||||
stops int
|
||||
}
|
||||
|
||||
func newFake(name string) *fakeController {
|
||||
return &fakeController{name: name, started: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (f *fakeController) Name() string { return f.name }
|
||||
func (f *fakeController) IsEnabled() bool { return true }
|
||||
func (f *fakeController) IsMarkdown() bool { return false }
|
||||
|
||||
func (f *fakeController) Start() error {
|
||||
f.mu.Lock()
|
||||
f.starts++
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case f.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeController) Stop() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stops++
|
||||
}
|
||||
|
||||
func (f *fakeController) lifecycle() (starts, stops int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.starts, f.stops
|
||||
}
|
||||
|
||||
func (f *fakeController) SendStatusChange(node.StatusChange) error { return nil }
|
||||
func (f *fakeController) SendServerList(string, string) error { return nil }
|
||||
func (f *fakeController) SendExecuteResult(string, string, string, string) error { return nil }
|
||||
func (f *fakeController) SendAlert(Alert) error { return nil }
|
||||
|
||||
// waitStarted blocks until the controller's Start has run.
|
||||
func (f *fakeController) waitStarted(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("controller %s was never started", f.name)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestManager() *Manager {
|
||||
return &Manager{controllers: make(map[string]Controller)}
|
||||
}
|
||||
|
||||
// routedTo returns the controller the manager currently routes the given name
|
||||
// to.
|
||||
func (m *Manager) routedTo(name string) Controller {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.controllers[name]
|
||||
}
|
||||
|
||||
// heldOffline returns the Offline notification the manager is currently holding
|
||||
// for the given node, if any. It lets a test drive the hold/fire/release race
|
||||
// deterministically instead of racing a real timer.
|
||||
func (m *Manager) heldOffline(uuid string) *flapPending {
|
||||
m.flapMu.Lock()
|
||||
defer m.flapMu.Unlock()
|
||||
return m.flapPending[uuid]
|
||||
}
|
||||
|
||||
// recordingController keeps every status change it is handed, so a test can
|
||||
// assert on what actually reached the controllers. The embedded fakeController
|
||||
// satisfies the rest of the Controller interface.
|
||||
type recordingController struct {
|
||||
*fakeController
|
||||
changes chan node.StatusChange
|
||||
}
|
||||
|
||||
func newRecording(name string) *recordingController {
|
||||
return &recordingController{
|
||||
fakeController: newFake(name),
|
||||
changes: make(chan node.StatusChange, 16),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recordingController) SendStatusChange(change node.StatusChange) error {
|
||||
r.changes <- change
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitChange blocks until a status change reaches the controller, failing the
|
||||
// test rather than hanging if none does.
|
||||
func awaitChange(t *testing.T, rec *recordingController, what string) node.StatusChange {
|
||||
t.Helper()
|
||||
select {
|
||||
case change := <-rec.changes:
|
||||
return change
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for the %s notification", what)
|
||||
return node.StatusChange{}
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoChange asserts that no status change reaches the controller for the
|
||||
// given window. Only a negative assertion needs a real wait; the manager has no
|
||||
// observable "decided to stay silent" event.
|
||||
func expectNoChange(t *testing.T, rec *recordingController, window time.Duration, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case change := <-rec.changes:
|
||||
t.Fatalf("unexpected %s notification: %+v", what, change)
|
||||
case <-time.After(window):
|
||||
}
|
||||
}
|
||||
|
||||
func offlineChange(uuid string) node.StatusChange {
|
||||
return node.StatusChange{Event: "Offline", UUID: uuid, Name: uuid, OldOnline: true, NewOnline: false}
|
||||
}
|
||||
|
||||
func onlineChange(uuid string) node.StatusChange {
|
||||
return node.StatusChange{Event: "Online", UUID: uuid, Name: uuid, OldOnline: false, NewOnline: true}
|
||||
}
|
||||
|
||||
// flapTestManager returns a manager with a recording controller installed and
|
||||
// the flap window stubbed to tolerance, so the tests never touch the global
|
||||
// bot_node_config.json or wait on real seconds. ReplaceAll makes the controller
|
||||
// routable before it starts it, so no start handshake is needed here.
|
||||
func flapTestManager(tolerance time.Duration) (*Manager, *recordingController) {
|
||||
m := newTestManager()
|
||||
m.flapToleranceFor = func(string) time.Duration { return tolerance }
|
||||
rec := newRecording("recorder")
|
||||
m.ReplaceAll([]Controller{rec}, config.ControllerMethodConfig{})
|
||||
return m, rec
|
||||
}
|
||||
|
||||
// TestFlapInhibitionSuppressesBothHalves is the core guarantee: a node that
|
||||
// returns inside its flap window produces no notification at all, so a flapping
|
||||
// node does not flood the controllers.
|
||||
func TestFlapInhibitionSuppressesBothHalves(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("flappy"))
|
||||
m.NotifyStatusChange(onlineChange("flappy"))
|
||||
|
||||
expectNoChange(t, rec, 300*time.Millisecond, "suppressed offline/online pair")
|
||||
|
||||
if held := m.heldOffline("flappy"); held != nil {
|
||||
t.Error("the held offline notification was not released when the node returned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionReportsLateOffline: a node still down when the window
|
||||
// elapses reports Offline as usual, however late the notification is.
|
||||
func TestFlapInhibitionReportsLateOffline(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("down"))
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" || change.UUID != "down" {
|
||||
t.Errorf("got %+v, want the Offline change for node down", change)
|
||||
}
|
||||
if held := m.heldOffline("down"); held != nil {
|
||||
t.Error("the held notification was still pending after it was dispatched")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionReportsRecoveryAfterWindow: once the Offline has gone out,
|
||||
// the node's eventual return is a genuine recovery and must be reported. This
|
||||
// is the honest half of the "both or neither, never Offline alone" rule.
|
||||
func TestFlapInhibitionReportsRecoveryAfterWindow(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("down"))
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Fatalf("got %+v, want the Offline change", change)
|
||||
}
|
||||
|
||||
m.NotifyStatusChange(onlineChange("down"))
|
||||
if change := awaitChange(t, rec, "online"); change.Event != "Online" {
|
||||
t.Errorf("got %+v, want the Online change", change)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionDisabledWithoutTolerance guards the previous behavior: with
|
||||
// no window (the default for every node), an Offline still goes out at once.
|
||||
func TestFlapInhibitionDisabledWithoutTolerance(t *testing.T) {
|
||||
m, rec := flapTestManager(0)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("plain"))
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Errorf("got %+v, want the Offline change", change)
|
||||
}
|
||||
if held := m.heldOffline("plain"); held != nil {
|
||||
t.Error("a node without a flap window must not have a held notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionHonorsEnableStatusNotify pins the gate ordering: a node
|
||||
// whose notifications are switched off is skipped before any hold is taken, so
|
||||
// it cannot report a late Offline either.
|
||||
func TestFlapInhibitionHonorsEnableStatusNotify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bot_node_config.json")
|
||||
content := `{"muted": {"enableStatusNotify": false, "flap_tolerant": 600}}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write node config: %v", err)
|
||||
}
|
||||
if err := config.LoadBotNodeConfig(path); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
// Leave nothing behind for the other tests in this package, which reach the
|
||||
// same package-global node config.
|
||||
t.Cleanup(func() {
|
||||
if err := config.LoadBotNodeConfig(filepath.Join(dir, "empty.json")); err != nil {
|
||||
t.Errorf("reset node config: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// The stub reports a window, so only the allow-list can be responsible for
|
||||
// the silence.
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("muted"))
|
||||
|
||||
expectNoChange(t, rec, 100*time.Millisecond, "offline for a muted node")
|
||||
if held := m.heldOffline("muted"); held != nil {
|
||||
t.Error("a muted node must not have its Offline held")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionClaimOrder pins both sides of the hold/fire/release race
|
||||
// deterministically, with a window long enough that the real timer never fires:
|
||||
// whichever claims the held notification first wins, and the loser sees it gone.
|
||||
func TestFlapInhibitionClaimOrder(t *testing.T) {
|
||||
t.Run("release first suppresses the pair", func(t *testing.T) {
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
held := m.heldOffline("node")
|
||||
if held == nil {
|
||||
t.Fatal("the offline notification was not held")
|
||||
}
|
||||
if !m.releaseHeldOffline("node") {
|
||||
t.Fatal("releaseHeldOffline reported no pending notification")
|
||||
}
|
||||
// The timer goroutine loses the claim and must not dispatch.
|
||||
m.fireHeldOffline("node", held)
|
||||
|
||||
expectNoChange(t, rec, 100*time.Millisecond, "notification after a release")
|
||||
})
|
||||
|
||||
t.Run("fire first reports the offline alone", func(t *testing.T) {
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
held := m.heldOffline("node")
|
||||
if held == nil {
|
||||
t.Fatal("the offline notification was not held")
|
||||
}
|
||||
m.fireHeldOffline("node", held)
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Errorf("got %+v, want the Offline change", change)
|
||||
}
|
||||
if m.releaseHeldOffline("node") {
|
||||
t.Error("a fired notification must not be claimable again")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopAllDropsHeldNotifications: a hold must not outlive the controllers it
|
||||
// was meant for.
|
||||
func TestStopAllDropsHeldNotifications(t *testing.T) {
|
||||
m, _ := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
m.StopAll()
|
||||
|
||||
if held := m.heldOffline("node"); held != nil {
|
||||
t.Error("StopAll left a held offline notification armed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAllInstallsAndStarts(t *testing.T) {
|
||||
m := newTestManager()
|
||||
alpha, beta := newFake("alpha"), newFake("beta")
|
||||
|
||||
m.ReplaceAll([]Controller{alpha, beta}, config.ControllerMethodConfig{})
|
||||
|
||||
alpha.waitStarted(t)
|
||||
beta.waitStarted(t)
|
||||
|
||||
if got := m.routedTo("alpha"); got != Controller(alpha) {
|
||||
t.Errorf("alpha is not routable after ReplaceAll: %v", got)
|
||||
}
|
||||
if got := m.routedTo("beta"); got != Controller(beta) {
|
||||
t.Errorf("beta is not routable after ReplaceAll: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the
|
||||
// old controllers must be shut down, or a rebuilt onebot or Telegram controller
|
||||
// would leave its previous connection running.
|
||||
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
|
||||
m := newTestManager()
|
||||
outgoing := newFake("alpha")
|
||||
m.ReplaceAll([]Controller{outgoing}, config.ControllerMethodConfig{})
|
||||
outgoing.waitStarted(t)
|
||||
|
||||
incoming := newFake("alpha")
|
||||
m.ReplaceAll([]Controller{incoming}, config.ControllerMethodConfig{})
|
||||
incoming.waitStarted(t)
|
||||
|
||||
if starts, stops := outgoing.lifecycle(); starts != 1 || stops != 1 {
|
||||
t.Errorf("outgoing controller lifecycle = %d starts / %d stops, want 1/1", starts, stops)
|
||||
}
|
||||
if _, stops := incoming.lifecycle(); stops != 0 {
|
||||
t.Errorf("incoming controller was stopped %d times", stops)
|
||||
}
|
||||
if got := m.routedTo("alpha"); got != Controller(incoming) {
|
||||
t.Error("routing still points at the outgoing controller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAllDropsChannelsLeftOut(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
|
||||
|
||||
m.ReplaceAll([]Controller{newFake("beta")}, config.ControllerMethodConfig{})
|
||||
|
||||
if got := m.routedTo("alpha"); got != nil {
|
||||
t.Errorf("a channel missing from the new set is still routable: %v", got)
|
||||
}
|
||||
if got := m.routedTo("beta"); got == nil {
|
||||
t.Error("the surviving channel is not routable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRebuild(t *testing.T) {
|
||||
m := newTestManager()
|
||||
base := config.ControllerMethodConfig{
|
||||
Email: config.EmailConfig{Enabled: true, SMTPPort: 587, To: []string{"a@example.com"}},
|
||||
}
|
||||
|
||||
if !m.NeedsRebuild(base) {
|
||||
t.Error("a manager with nothing installed must report that a rebuild is needed")
|
||||
}
|
||||
|
||||
m.ReplaceAll(nil, base)
|
||||
if m.NeedsRebuild(base) {
|
||||
t.Error("the settings the set was built from must not ask for another rebuild")
|
||||
}
|
||||
|
||||
changed := base
|
||||
changed.Email.Enabled = false
|
||||
if !m.NeedsRebuild(changed) {
|
||||
t.Error("a changed email setting must ask for a rebuild")
|
||||
}
|
||||
|
||||
// The section carries maps and slices, so it is compared by value rather
|
||||
// than by identity: equal content must not trigger a rebuild.
|
||||
withHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
|
||||
}
|
||||
m.ReplaceAll(nil, withHeaders)
|
||||
equalHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "t"}},
|
||||
}
|
||||
if m.NeedsRebuild(equalHeaders) {
|
||||
t.Error("equal header maps must not ask for a rebuild")
|
||||
}
|
||||
|
||||
differentHeaders := config.ControllerMethodConfig{
|
||||
Webhook: config.WebhookConfig{Headers: map[string]string{"X-Token": "other"}},
|
||||
}
|
||||
if !m.NeedsRebuild(differentHeaders) {
|
||||
t.Error("a changed header must ask for a rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceAllDuringNotification drives ReplaceAll while notifications are
|
||||
// being routed. Run with -race: the swap replaces the map the routing path
|
||||
// reads, which is what the registry lock exists to make safe.
|
||||
func TestReplaceAllDuringNotification(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.ReplaceAll([]Controller{newFake("alpha")}, config.ControllerMethodConfig{})
|
||||
|
||||
var readers, writers sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
m.NotifyStatusChange(node.StatusChange{UUID: "u1", Name: "alpha", Event: "Online"})
|
||||
_ = m.IsMarkdown("alpha")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
m.ReplaceAll([]Controller{newFake("alpha"), newFake("beta")}, config.ControllerMethodConfig{})
|
||||
}
|
||||
}()
|
||||
|
||||
// Let the writer finish, then release the readers: they only return once
|
||||
// stop is closed.
|
||||
writers.Wait()
|
||||
close(stop)
|
||||
readers.Wait()
|
||||
|
||||
if got := m.routedTo("beta"); got == nil {
|
||||
t.Error("the last installed set is not routable")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package pipes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
gomail "gopkg.in/mail.v2"
|
||||
|
||||
@@ -14,22 +15,34 @@ import (
|
||||
)
|
||||
|
||||
// EmailController handles email notifications via SMTP.
|
||||
//
|
||||
// cfg is written once, by the constructor, and never again: a controller that
|
||||
// needs different settings is replaced wholesale by the manager rather than
|
||||
// reconfigured in place, so the send methods can read it without locking.
|
||||
type EmailController struct {
|
||||
cfg config.EmailConfig
|
||||
}
|
||||
|
||||
// NewEmailController creates a new Email controller.
|
||||
func NewEmailController(cfg config.EmailConfig) *EmailController {
|
||||
if cfg.NetworkUseProxy {
|
||||
// Route SMTP through the HTTP CONNECT proxy. NetDialTimeout is
|
||||
// gomail's documented hook for overriding how the SMTP connection is
|
||||
// dialed. There is a single global email channel, so overriding it
|
||||
// unconditionally when the flag is set is safe.
|
||||
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
|
||||
}
|
||||
applyEmailProxy(cfg.NetworkUseProxy)
|
||||
return &EmailController{cfg: cfg}
|
||||
}
|
||||
|
||||
// applyEmailProxy routes SMTP through the HTTP CONNECT proxy, or restores a
|
||||
// direct dial. gomail exposes the dial path as the package-level
|
||||
// NetDialTimeout, whose own default is net.DialTimeout, so turning the proxy
|
||||
// off has to put that back rather than leave the hook in place. There is a
|
||||
// single global email channel, so setting a package-level hook here is
|
||||
// unambiguous.
|
||||
func applyEmailProxy(useProxy bool) {
|
||||
if useProxy {
|
||||
gomail.NetDialTimeout = netproxy.DialWithTimeout(true)
|
||||
return
|
||||
}
|
||||
gomail.NetDialTimeout = net.DialTimeout
|
||||
}
|
||||
|
||||
// Name returns the controller name.
|
||||
func (e *EmailController) Name() string {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
)
|
||||
|
||||
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
|
||||
//
|
||||
// cfg and httpClient are written once, by the constructor, and never again: a
|
||||
// controller that needs different settings is replaced wholesale by the manager
|
||||
// rather than reconfigured in place, so the send methods can read them without
|
||||
// locking.
|
||||
type NtfyController struct {
|
||||
cfg config.NtfyConfig
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -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, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params["group_id"].(float64) != 12345 || params["message"].(string) != "hello" {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
var echo string
|
||||
if err := json.Unmarshal(req.Echo, &echo); err != nil {
|
||||
t.Fatalf("echo is not a JSON string: %s", req.Echo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodylessActionSendsEmptyParams checks actions without arguments still send
|
||||
// a params object (never null), which some adapters reject.
|
||||
func TestBodylessActionSendsEmptyParams(t *testing.T) {
|
||||
f := newFakeOneBot(t)
|
||||
|
||||
c := f.client(t)
|
||||
go c.Listen(func([]byte) {})
|
||||
defer c.Stop()
|
||||
|
||||
if _, err := c.GetGroupList(); err != nil {
|
||||
t.Fatalf("GetGroupList: %v", err)
|
||||
}
|
||||
|
||||
req := <-f.apiRequests
|
||||
if req.Action != "get_group_list" {
|
||||
t.Fatalf("action = %q, want get_group_list", req.Action)
|
||||
}
|
||||
if string(req.Params) != "{}" {
|
||||
t.Fatalf("params = %s, want {}", req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMsgFromEventCallbackDoesNotDeadlock is the regression test for the
|
||||
// reason the client uses two sockets: the event handler replies with SendMsg from
|
||||
// inside the event read loop, so a response read on that same connection would
|
||||
// never arrive.
|
||||
func TestSendMsgFromEventCallbackDoesNotDeadlock(t *testing.T) {
|
||||
f := newFakeOneBot(t)
|
||||
|
||||
c := f.client(t)
|
||||
|
||||
type result struct {
|
||||
resp *APIResponse
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go c.Listen(func(raw []byte) {
|
||||
resp, err := c.SendMsg("group", 1, "pong", false, 0)
|
||||
done <- result{resp, err}
|
||||
})
|
||||
defer c.Stop()
|
||||
|
||||
evConn := f.eventConn(t)
|
||||
event := []byte(`{"post_type":"message","message_type":"group","group_id":1,"user_id":2,"raw_message":"/ping","self_id":9}`)
|
||||
if err := evConn.WriteMessage(websocket.TextMessage, event); err != nil {
|
||||
t.Fatalf("write event: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
t.Fatalf("SendMsg from event callback: %v", r.err)
|
||||
}
|
||||
if r.resp.Status != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", r.resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("deadlock: SendMsg did not return from within the event callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoKey covers the response-matcher: an absent or null echo is not a
|
||||
// response, and both string and non-string echoes normalize to a key.
|
||||
func TestEchoKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
key string
|
||||
ok bool
|
||||
}{
|
||||
{`"42"`, "42", true},
|
||||
{`42`, "42", true},
|
||||
{`null`, "", false},
|
||||
{``, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
key, ok := echoKey(json.RawMessage(tc.in))
|
||||
if key != tc.key || ok != tc.ok {
|
||||
t.Errorf("echoKey(%q) = (%q, %v), want (%q, %v)", tc.in, key, ok, tc.key, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-46
@@ -1,4 +1,4 @@
|
||||
package qq_napcat
|
||||
package qq_onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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.
|
||||
type oneBotEvent struct {
|
||||
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.
|
||||
}
|
||||
|
||||
// QQController handles QQ Bot interactions by connecting directly to NapCat.
|
||||
// QQController handles QQ Bot interactions by connecting directly to OneBot.
|
||||
type QQController struct {
|
||||
cfg config.QQConfig
|
||||
napcatClient *Client
|
||||
cfg config.OneBotConfig
|
||||
onebotClient *Client
|
||||
}
|
||||
|
||||
// NewQQController creates a new QQ (Napcat) controller.
|
||||
func NewQQController(cfg config.QQConfig) *QQController {
|
||||
// NewQQController creates a new QQ (OneBot) controller.
|
||||
func NewQQController(cfg config.OneBotConfig) *QQController {
|
||||
q := &QQController{cfg: cfg}
|
||||
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
|
||||
}
|
||||
|
||||
// Name returns the controller name.
|
||||
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 {
|
||||
if !q.cfg.Enabled {
|
||||
// postLog.Info("QQ (Napcat) controller is disabled")
|
||||
return nil
|
||||
}
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is 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() {
|
||||
defer func() {
|
||||
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
|
||||
@@ -75,10 +76,10 @@ func (q *QQController) Start() error {
|
||||
|
||||
// Stop shuts down the QQ controller and its WebSocket listener.
|
||||
func (q *QQController) Stop() {
|
||||
if q.napcatClient != nil {
|
||||
q.napcatClient.Stop()
|
||||
if q.onebotClient != nil {
|
||||
q.onebotClient.Stop()
|
||||
}
|
||||
postLog.Info("QQ (Napcat) controller stopped")
|
||||
postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the controller is enabled.
|
||||
@@ -92,11 +93,11 @@ func (q *QQController) IsMarkdown() bool {
|
||||
return q.cfg.Markdown
|
||||
}
|
||||
|
||||
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
||||
func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
// handleOneBotEvent processes a raw OneBot event received from the OneBot WebSocket.
|
||||
func (q *QQController) handleOneBotEvent(raw []byte) {
|
||||
var ev oneBotEvent
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,16 +109,16 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
return
|
||||
}
|
||||
debugMode := cfg.System.DebugMode
|
||||
showAction := debugMode && cfg.Debug.ShowNapcatAction
|
||||
showAction := debugMode && cfg.Debug.ShowOneBotAction
|
||||
|
||||
if debugMode && cfg.Debug.ShowNapcatMsg {
|
||||
postLog.Debug("Napcat WS event received: " + string(raw))
|
||||
if debugMode && cfg.Debug.ShowOneBotMsg {
|
||||
postLog.Debug("[qq_onebot] OneBot WS event received: " + string(raw))
|
||||
}
|
||||
|
||||
// Only handle message events; ignore notice/request/meta_event.
|
||||
if ev.PostType != "message" {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS event: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -126,9 +127,9 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
if q.isSelfMessage(ev) && !debugMode {
|
||||
return
|
||||
}
|
||||
if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg {
|
||||
if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -149,19 +150,19 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
response := q.processCommand(cmd)
|
||||
if response == "" {
|
||||
if showAction {
|
||||
postLog.Debug("Napcat WS command discarded: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reply in the same chat.
|
||||
if ev.MessageType == "private" {
|
||||
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
||||
postLog.Warning("Failed to send NapCat private reply: " + err.Error())
|
||||
if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
||||
postLog.Warning("Failed to send NapCat group reply: " + err.Error())
|
||||
if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +180,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
if q.cfg.ListenMethod == "at" {
|
||||
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
|
||||
if !strings.Contains(text, atMention) {
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction {
|
||||
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
|
||||
postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
|
||||
}
|
||||
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.
|
||||
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
|
||||
postLog.Debug(fmt.Sprintf("[qq_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 {
|
||||
postLog.Error("Napcat command processing failed: " + err.Error())
|
||||
postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
|
||||
return ""
|
||||
}
|
||||
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -341,35 +343,35 @@ func (q *QQController) SendAlert(alert controller.Alert) error {
|
||||
}
|
||||
|
||||
func (q *QQController) sendGroupMessage(groupID string, message string) {
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var groupIDInt int64
|
||||
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
|
||||
}
|
||||
|
||||
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err))
|
||||
if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QQController) sendPrivateMessage(userID string, message string) {
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("Cannot send QQ private message: NapCat client not initialized")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var userIDInt int64
|
||||
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
|
||||
}
|
||||
|
||||
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err))
|
||||
if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
)
|
||||
|
||||
// WebhookController handles notifications via generic HTTP webhooks.
|
||||
//
|
||||
// cfg and httpClient are written once, by the constructor, and never again: a
|
||||
// controller that needs different settings is replaced wholesale by the manager
|
||||
// rather than reconfigured in place, so the send methods can read them without
|
||||
// locking.
|
||||
type WebhookController struct {
|
||||
cfg config.WebhookConfig
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -3,6 +3,8 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// 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.
|
||||
if cmd.ChatType == "group" {
|
||||
// Group commands are only honored from trusted groups, otherwise discard.
|
||||
// postLog.Debug(fmt.Sprintf("trustedGroups: %v", trustedGroups))
|
||||
if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
|
||||
return "", nil
|
||||
}
|
||||
postLog.Debug("isTrustedGroup")
|
||||
}
|
||||
|
||||
// Commands that require special permission must be sent by an admin.
|
||||
@@ -37,6 +41,7 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
||||
if response == "" && listenMethod == "at" {
|
||||
return "Unknown command: /" + cmd.Command, nil
|
||||
}
|
||||
// postLog.Debug("[controller] Trigger.resp: " + response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ func (c *Client) FetchNodes() ([]NodeInfo, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// network proxy configured in the system config. Each caller decides whether
|
||||
// to use the proxy by passing its own useProxy flag (the per-channel
|
||||
// networkUseProxy setting), so proxying is opt-in per channel.
|
||||
//
|
||||
// The opt-in is captured when a client is built, but the proxy address is not:
|
||||
// it is read again on every request and every dial, so editing
|
||||
// system.networkProxy takes effect on clients that already exist.
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
@@ -39,19 +43,23 @@ func proxyURL() *url.URL {
|
||||
}
|
||||
|
||||
// ProxyFunc returns a transport proxy function that routes requests through
|
||||
// the configured network proxy when enabled. It returns nil when the caller
|
||||
// opts out or no proxy is configured, meaning direct connection. The returned
|
||||
// function is compatible with both http.Transport.Proxy and
|
||||
// websocket.Dialer.Proxy.
|
||||
// the configured network proxy when enabled, and nil when the caller opts out
|
||||
// of proxying entirely. The returned function is compatible with both
|
||||
// http.Transport.Proxy and websocket.Dialer.Proxy.
|
||||
//
|
||||
// The proxy address is resolved on every call rather than once here, so a
|
||||
// settings update that changes system.networkProxy reaches a client that was
|
||||
// already built. That is also why opting out is the only case that returns nil:
|
||||
// a function resolved to nothing at construction time would pin its client to
|
||||
// whatever was configured then. A nil URL from the returned function means no
|
||||
// proxy is configured and the request goes direct.
|
||||
func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) {
|
||||
if !useProxy {
|
||||
return nil
|
||||
}
|
||||
u := proxyURL()
|
||||
if u == nil {
|
||||
return nil
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return proxyURL(), nil
|
||||
}
|
||||
return http.ProxyURL(u)
|
||||
}
|
||||
|
||||
// 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
|
||||
// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout
|
||||
// to send SMTP over the proxy.
|
||||
//
|
||||
// Like ProxyFunc it reads the proxy address per dial, so clearing or changing
|
||||
// system.networkProxy reaches a dialer that already exists.
|
||||
func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
u := proxyURL()
|
||||
return func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
if !useProxy || u == nil {
|
||||
if !useProxy {
|
||||
return net.DialTimeout(network, addr, timeout)
|
||||
}
|
||||
u := proxyURL()
|
||||
if u == nil {
|
||||
return net.DialTimeout(network, addr, timeout)
|
||||
}
|
||||
return dialViaProxy(u, addr, timeout)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
)
|
||||
|
||||
// publishConfig writes a config.json and makes it the configuration in effect,
|
||||
// which is what a settings update does.
|
||||
func publishConfig(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
if _, err := config.LoadGlobalConfig(path); err != nil {
|
||||
t.Fatalf("LoadGlobalConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve runs a proxy function and returns the URL it chose, or "" when it
|
||||
// chose a direct connection.
|
||||
func resolve(t *testing.T, proxy func(*http.Request) (*url.URL, error)) string {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
u, err := proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy function: %v", err)
|
||||
}
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// TestProxyFuncResolvesPerCall is the property that makes system.networkProxy
|
||||
// hot-reloadable: the function handed to a transport keeps reading the live
|
||||
// configuration instead of the address that was configured when it was built.
|
||||
func TestProxyFuncResolvesPerCall(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
proxy := ProxyFunc(true)
|
||||
if proxy == nil {
|
||||
t.Fatal("ProxyFunc(true) returned nil, so the channel would never proxy")
|
||||
}
|
||||
if got := resolve(t, proxy); got != "http://127.0.0.1:7890" {
|
||||
t.Errorf("first resolution = %q", got)
|
||||
}
|
||||
|
||||
// The same function must follow a settings update.
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:8888"}}`)
|
||||
if got := resolve(t, proxy); got != "http://127.0.0.1:8888" {
|
||||
t.Errorf("after a settings update the same function resolved %q", got)
|
||||
}
|
||||
|
||||
// Clearing the proxy falls back to a direct connection.
|
||||
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
||||
if got := resolve(t, proxy); got != "" {
|
||||
t.Errorf("a cleared proxy still resolved %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncOptOutReturnsNil(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
// A channel with networkUseProxy off must not be handed a function at all,
|
||||
// so its transport keeps the default direct dialing.
|
||||
if ProxyFunc(false) != nil {
|
||||
t.Error("ProxyFunc(false) must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncNormalizesMissingScheme(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"127.0.0.1:7890"}}`)
|
||||
|
||||
if got := resolve(t, ProxyFunc(true)); got != "http://127.0.0.1:7890" {
|
||||
t.Errorf("resolved %q, want the http:// prefix added", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFuncIgnoresUnusableProxy(t *testing.T) {
|
||||
// A value that cannot be parsed must leave the client dialing directly
|
||||
// rather than failing every request.
|
||||
publishConfig(t, `{"system":{"networkProxy":"://missing-scheme"}}`)
|
||||
|
||||
if got := resolve(t, ProxyFunc(true)); got != "" {
|
||||
t.Errorf("an unparseable proxy resolved %q, want a direct connection", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialWithTimeoutDialsDirectlyWithoutProxy covers the path a cleared
|
||||
// system.networkProxy takes: the dialer was built while a proxy was configured,
|
||||
// and must fall back to a direct dial once there is none.
|
||||
func TestDialWithTimeoutDialsDirectlyWithoutProxy(t *testing.T) {
|
||||
publishConfig(t, `{"system":{"networkProxy":"http://127.0.0.1:7890"}}`)
|
||||
|
||||
dial := DialWithTimeout(true)
|
||||
if dial == nil {
|
||||
t.Fatal("DialWithTimeout(true) returned nil")
|
||||
}
|
||||
|
||||
// No proxy is listening on that address, so a dial attempted now would
|
||||
// fail; clearing the setting is what makes the direct path reachable.
|
||||
publishConfig(t, `{"system":{"networkProxy":""}}`)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := dial("tcp", ln.Addr().String(), 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial through a cleared proxy: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
@@ -107,6 +107,7 @@ type StatusChangeCallback func(change StatusChange)
|
||||
// All methods are thread-safe.
|
||||
type Tracker struct {
|
||||
mu sync.RWMutex
|
||||
count int
|
||||
nodes map[string]*Node // uuid → Node
|
||||
uuidToName map[string]string // uuid → name (from node list)
|
||||
onlineSet map[string]bool // which uuids are currently online
|
||||
@@ -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
|
||||
// static Info metadata. entries maps UUID → node metadata from the Komari node
|
||||
// list.
|
||||
@@ -175,6 +183,8 @@ func (t *Tracker) UpdateNodeList(entries map[string]NodeListEntry) {
|
||||
t.knownUUIDs[uuid] = true
|
||||
}
|
||||
|
||||
t.count = len(entries)
|
||||
|
||||
t.uuidToName = make(map[string]string, len(entries))
|
||||
for uuid, entry := range entries {
|
||||
t.uuidToName[uuid] = entry.Name
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"nukumizu-backend/global"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"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/komari"
|
||||
"nukumizu-backend/internal/node"
|
||||
@@ -58,6 +58,27 @@ func main() {
|
||||
postLog.SetDebugMode(cfg.System.DebugMode)
|
||||
postLog.InitLogBroadcaster()
|
||||
|
||||
// A settings update replaces the configuration in memory; these hooks push
|
||||
// the new values into the state that was derived from the old one. The
|
||||
// logger's debug flag is process-wide rather than read at every log call,
|
||||
// and each controller holds its own copy of its channel settings plus the
|
||||
// clients built from them.
|
||||
config.OnReload(func(updated *config.Config) {
|
||||
postLog.SetDebugMode(updated.System.DebugMode)
|
||||
|
||||
mgr := controller.GetManager()
|
||||
if mgr == nil || !mgr.NeedsRebuild(updated.ControllerMethod) {
|
||||
return
|
||||
}
|
||||
|
||||
// Controller settings changed. Each controller reads its settings into
|
||||
// fields when it is built, and the OneBot and Telegram ones own
|
||||
// connections that cannot be re-pointed, so the change is applied by
|
||||
// replacing the whole set rather than reconfiguring it in place.
|
||||
postLog.Info("Controller settings changed; rebuilding every channel")
|
||||
mgr.ReplaceAll(buildControllers(updated), updated.ControllerMethod)
|
||||
})
|
||||
|
||||
dbPath := cfg.DBPath
|
||||
|
||||
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
|
||||
@@ -110,7 +131,7 @@ func main() {
|
||||
initControllers()
|
||||
|
||||
// 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
|
||||
// online/offline state.
|
||||
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() {
|
||||
cfg := config.Current()
|
||||
mgr := controller.GetManager()
|
||||
if mgr == nil {
|
||||
if mgr == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}()
|
||||
mgr.ReplaceAll(buildControllers(cfg), cfg.ControllerMethod)
|
||||
}
|
||||
|
||||
// startBackgroundTasks starts periodic background goroutines.
|
||||
@@ -294,11 +260,15 @@ func startBackgroundTasks(_ *config.Config) {
|
||||
if client == nil {
|
||||
continue
|
||||
}
|
||||
orgNodeCount := node.GetTracker().GetNodeCount()
|
||||
nodes, err := client.FetchNodes()
|
||||
if err != nil {
|
||||
postLog.Warning("Failed to refresh node list: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if node.GetTracker().GetNodeCount() != orgNodeCount {
|
||||
postLog.Info(fmt.Sprintf("Refreshed node list: %d nodes", len(nodes)))
|
||||
}
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
tracker.UpdateNodeList(komari.BuildNodeListData(nodes))
|
||||
|
||||
@@ -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" .
|
||||
Reference in new issue
Block a user