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