10 Commits
Author SHA1 Message Date
NanamiAdmin 8c2159ff0c chore(variables): update version and build number to 0.2.2 and 7 respectively
Build / ubuntu-latest (push) Failing after 9s
Build / windows-latest (push) Canceled after 6m3s
2026-10-13 20:46:48 +08:00
NanamiAdmin bc5cb417eb refactor(overview, settings): remove some unnecessary hints and help
Build / ubuntu-latest (push) Canceled after 1m2s
Build / windows-latest (push) Canceled after 1m13s
2026-10-13 20:45:43 +08:00
NanamiAdmin 1a7a4cdc07 feat(node): implement flap tolerance settings and options dialog in frontend
Build / windows-latest (push) Failing after 1m15s
Build / ubuntu-latest (push) Failing after 3m5s
2026-10-13 20:39:05 +08:00
NanamiAdmin 9137d53286 feat(debug): add ShowFlapTolerantEcho flag for enhanced logging control
Build / ubuntu-latest (push) Failing after 3m0s
Build / windows-latest (push) Failing after 3m47s
2026-10-12 17:34:55 +08:00
NanamiAdmin 03e74f49c3 feat(controller): add flap inhibition for each controllers
Build / ubuntu-latest (push) Failing after 3m3s
Build / windows-latest (push) Failing after 11m25s
2026-10-12 17:17:55 +08:00
NanamiAdmin 0c581abc4c refactor(qq_onebot): replace all http handler with a brand new websocket handler, seperate with msg recver
Build / ubuntu-latest (push) Failing after 3m8s
Build / windows-latest (push) Failing after 56m41s
2026-10-11 15:28:19 +08:00
NanamiAdmin ba0d8539b3 refactor(qq_onebot): update log messages to use consistent prefix
Build / windows-latest (push) Failing after 3m7s
Build / ubuntu-latest (push) Failing after 3m53s
2026-10-11 14:36:59 +08:00
NanamiAdmin 1b11be2427 style(qq_onebot): rename all NapCat related to OneBot
Build / ubuntu-latest (push) Failing after 3m56s
Build / windows-latest (push) Canceled after 34m52s
2026-10-11 13:58:26 +08:00
NanamiAdmin 72d0d587cb feat(run): add script to run server with build metadata
Build / ubuntu-latest (push) Failing after 3m1s
Build / windows-latest (push) Failing after 33m0s
2026-10-04 15:27:42 +08:00
NanamiAdmin c5a80bd952 docs(changelog): update version format for 0.2.1.6 pre-release
Build / ubuntu-latest (push) Failing after 5m44s
Build / windows-latest (push) Canceled after 24m16s
2026-10-04 15:05:24 +08:00
21 changed files with 1668 additions and 466 deletions

No files matched your search

+5 -5
View File
@@ -77,9 +77,9 @@ nukumizu-backend/
│ ├── email.go # Email notification pipe │ ├── email.go # Email notification pipe
│ ├── ntfy.go # ntfy notification pipe │ ├── ntfy.go # ntfy notification pipe
│ ├── webhook.go # Outgoing webhook notification pipe │ ├── webhook.go # Outgoing webhook notification pipe
│ ├── qq_napcat/ │ ├── qq_onebot/
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller │ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
│ │ └── napcat.go # NapCat WebSocket + HTTP API client │ │ └── onebot.go # OneBot 11 WebSocket client (events + API)
│ └── telegram/ │ └── telegram/
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling) │ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
│ └── send.go # Message sending / splitting (Telegram Markdown) │ └── send.go # Message sending / splitting (Telegram Markdown)
@@ -101,7 +101,7 @@ nukumizu-backend/
- Go **1.25** or newer - Go **1.25** or newer
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it - [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host - A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint - For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 forward WebSocket endpoint (`/api` and `/event`)
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather) - For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`. Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
@@ -218,7 +218,7 @@ There are two configuration files, both read from the working directory unless o
Field notes: Field notes:
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT). - `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, QQ OneBot WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent. - `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown. - `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
- `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes). - `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
@@ -387,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 -1
View File
@@ -1,4 +1,4 @@
## Ver.0.2.1.6- ## Ver.0.2.1.6-d06d0bc.pre-release
### Features ### Features
- Most of settings now support hot reload when changed - Most of settings now support hot reload when changed
- [Network proxy]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075") - [Network proxy]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
+33 -10
View File
@@ -5,13 +5,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"time"
) )
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the // LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the // global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
// other config files this one is auto-generated and optional: a missing or // other config files this one is auto-generated and optional: a missing or
// empty file yields an empty map, so every node falls back to its default // empty file yields an empty map, so every node falls back to its defaults —
// enableStatusNotify value (true). // enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
func LoadBotNodeConfig(configPath string) error { func LoadBotNodeConfig(configPath string) error {
cfg := BotNodeMembers{} cfg := BotNodeMembers{}
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -44,6 +45,27 @@ func NodeStatusNotifyEnabled(uuid string) bool {
return *opts.EnableStatusNotify return *opts.EnableStatusNotify
} }
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
// hand-written value cannot overflow time.Duration.
const maxFlapTolerantSeconds = 86400
// NodeFlapTolerant returns how long an Offline notification for uuid should be
// held in order to absorb a quick reconnect, per bot_node_config.json's
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
// the value is zero or negative, or the config has not been loaded — in every
// case the caller falls back to notifying immediately.
func NodeFlapTolerant(uuid string) time.Duration {
opts, ok := BotNodes()[uuid]
if !ok || opts.FlapTolerant <= 0 {
return 0
}
seconds := opts.FlapTolerant
if seconds > maxFlapTolerantSeconds {
seconds = maxFlapTolerantSeconds
}
return time.Duration(seconds * float64(time.Second))
}
// LoadGlobalConfig reads and parses the configuration file, applies defaults, // LoadGlobalConfig reads and parses the configuration file, applies defaults,
// and stores it as a global singleton. // and stores it as a global singleton.
func LoadGlobalConfig(configPath string) (*Config, error) { func LoadGlobalConfig(configPath string) (*Config, error) {
@@ -65,15 +87,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
cfg.System.ListenPort = "8080" cfg.System.ListenPort = "8080"
} }
// Apply defaults for QQ controller. // Apply defaults for QQ_OneBot controller.
if cfg.ControllerMethod.QQ.ListenMethod == "" { if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
cfg.ControllerMethod.QQ.ListenMethod = "global" cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
} }
if cfg.ControllerMethod.QQ.NapcatAddr == "" { if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1" cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
} }
if cfg.ControllerMethod.QQ.NapcatPort == "" { if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
cfg.ControllerMethod.QQ.NapcatPort = "3000" cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
} }
// Apply defaults for Telegram controller. // Apply defaults for Telegram controller.
@@ -168,7 +190,8 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
// which is a plain registry of the nodes Komari reports (read back at startup // which is a plain registry of the nodes Komari reports (read back at startup
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID // by LoadBotNodeConfig). Entries already present are always preserved: a UUID
// Komari no longer reports on a given fetch is kept rather than deleted, and // Komari no longer reports on a given fetch is kept rather than deleted, and
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for // any per-node option a user set by hand — enableStatusNotify or flap_tolerant
// — is left untouched. UUIDs seen for
// the first time are added as an empty object, i.e. no parameter is written, so // the first time are added as an empty object, i.e. no parameter is written, so
// they inherit the enableStatusNotify default (true). The resulting JSON has // they inherit the enableStatusNotify default (true). The resulting JSON has
// its object keys emitted in sorted order by encoding/json, keeping the file // its object keys emitted in sorted order by encoding/json, keeping the file
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"encoding/json"
"testing"
"time"
"nukumizu-backend/global"
)
// TestNodeFlapTolerant covers the flap-inhibition window accessor against the
// per-node options in bot_node_config.json, including the hand-written values
// the comment on BotNodeOptions.FlapTolerant promises to accept.
func TestNodeFlapTolerant(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"seconds": { "flap_tolerant": 30 },
"fractional": { "flap_tolerant": 2.5 },
"zero": { "flap_tolerant": 0 },
"negative": { "flap_tolerant": -5 },
"unset": { "enableStatusNotify": true },
"absurd": { "flap_tolerant": 1e9 },
"throughput": { "flap_tolerant": 8, "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
cases := []struct {
name string
uuid string
want time.Duration
}{
{"whole seconds", "seconds", 30 * time.Second},
{"fractional seconds parse", "fractional", 2500 * time.Millisecond},
{"zero disables", "zero", 0},
{"negative disables", "negative", 0},
{"absent field disables", "unset", 0},
{"unknown node disables", "not-in-the-file", 0},
{"absurd value is clamped", "absurd", maxFlapTolerantSeconds * time.Second},
{"independent of enableStatusNotify", "throughput", 8 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NodeFlapTolerant(tc.uuid); got != tc.want {
t.Errorf("NodeFlapTolerant(%q) = %v, want %v", tc.uuid, got, tc.want)
}
})
}
}
// TestNodeFlapTolerantWithoutConfig pins the nil-safe default: before any
// bot_node_config.json has been loaded — and for a file that is missing or
// empty — no node has a window, so notifications keep their previous
// notify-immediately behavior.
func TestNodeFlapTolerantWithoutConfig(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, ``)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
if got := NodeFlapTolerant("anything"); got != 0 {
t.Errorf("node flap tolerance from an empty file = %v, want 0", got)
}
}
// nodeEntry reads one node's raw object back from the settings file, the way
// GetSettings serves it to the console.
func nodeEntry(t *testing.T, uuid string) map[string]interface{} {
t.Helper()
data, err := GetSettings(SettingBotNodeConfig)
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
var all map[string]map[string]interface{}
if err := json.Unmarshal(data, &all); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return all[uuid]
}
// TestFlapTolerantSettingsRoundTrip drives the payload the console's node card
// actually posts — { "<uuid>": { "flap_tolerant": <seconds> } } — through the
// settings API, so the frontend and the accessor are checked against each other
// and not just against hand-written files.
func TestFlapTolerantSettingsRoundTrip(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": false }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": 30},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 30*time.Second {
t.Errorf("NodeFlapTolerant after save = %v, want 30s", got)
}
// The sibling option must survive: the card saves only the field it edits.
entry := nodeEntry(t, "node-a")
if entry["enableStatusNotify"] != false {
t.Errorf("enableStatusNotify = %v, want it preserved as false", entry["enableStatusNotify"])
}
if entry["flap_tolerant"] != float64(30) {
t.Errorf("flap_tolerant on disk = %v, want 30", entry["flap_tolerant"])
}
}
// TestFlapTolerantSettingsRemoval: clearing the console's field sends a JSON
// null, which must remove the key rather than leave an explicit 0 behind.
func TestFlapTolerantSettingsRemoval(t *testing.T) {
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
"node-a": { "enableStatusNotify": true, "flap_tolerant": 30 }
}`)
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
t.Fatalf("LoadBotNodeConfig: %v", err)
}
patch := map[string]interface{}{
"node-a": map[string]interface{}{"flap_tolerant": nil},
}
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if got := NodeFlapTolerant("node-a"); got != 0 {
t.Errorf("NodeFlapTolerant after removal = %v, want 0", got)
}
entry := nodeEntry(t, "node-a")
if _, present := entry["flap_tolerant"]; present {
t.Error("flap_tolerant is still in the file after being cleared")
}
if entry["enableStatusNotify"] != true {
t.Errorf("enableStatusNotify = %v, want it preserved as true", entry["enableStatusNotify"])
}
}
+32 -20
View File
@@ -15,12 +15,13 @@ type SystemConfig struct {
// DebugConfig holds debug-level configuration. // DebugConfig holds debug-level configuration.
type DebugConfig struct { type DebugConfig struct {
ShowNapcatMsg bool `json:"showNapcatMsg"` ShowOneBotMsg bool `json:"showOneBotMsg"`
ShowNapcatAction bool `json:"showNapcatAction"` ShowOneBotAction bool `json:"showOneBotAction"`
ShowTelegramMsg bool `json:"showTelegramMsg"` ShowTelegramMsg bool `json:"showTelegramMsg"`
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"` ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"` ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"` OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
} }
// KomariAccount holds Komari login credentials. // KomariAccount holds Komari login credentials.
@@ -35,14 +36,14 @@ type KomariConfig struct {
Account KomariAccount `json:"account"` Account KomariAccount `json:"account"`
} }
// QQConfig holds QQ (Napcat) Bot controller configuration. // QQConfig holds QQ (OneBot) Bot controller configuration.
type QQConfig struct { type OneBotConfig struct {
Markdown bool `json:"markdown"` Markdown bool `json:"markdown"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NetworkUseProxy bool `json:"networkUseProxy"` NetworkUseProxy bool `json:"networkUseProxy"`
NapcatAddr string `json:"napcatAddr"` OneBotAddr string `json:"oneBotAddr"`
NapcatPort string `json:"napcatPort"` OneBotPort string `json:"oneBotPort"`
NapcatToken string `json:"napcatToken"` OneBotToken string `json:"oneBotToken"`
BotQQID int64 `json:"botQQID"` BotQQID int64 `json:"botQQID"`
ListenMethod string `json:"listenMethod"` ListenMethod string `json:"listenMethod"`
} }
@@ -96,11 +97,11 @@ type WebhookConfig struct {
// ControllerMethodConfig holds all controller method configurations. // ControllerMethodConfig holds all controller method configurations.
type ControllerMethodConfig struct { type ControllerMethodConfig struct {
QQ QQConfig `json:"qq(napcat)"` QQ_OneBot OneBotConfig `json:"qq_onebot"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"` Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"` Ntfy NtfyConfig `json:"ntfy"`
Webhook WebhookConfig `json:"webhook"` Webhook WebhookConfig `json:"webhook"`
} }
// WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are // WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are
@@ -119,7 +120,7 @@ type WebhookEndpointConfig struct {
Token string `json:"token"` Token string `json:"token"`
// NotifyPipes lists the notification channels the alert is delivered to, by // NotifyPipes lists the notification channels the alert is delivered to, by
// controller name (e.g. "qq(napcat)", "telegram", "email", "ntfy", // controller name (e.g. "qq(onebot)", "telegram", "email", "ntfy",
// "webhook"). // "webhook").
NotifyPipes []string `json:"notifyPipes"` NotifyPipes []string `json:"notifyPipes"`
} }
@@ -216,7 +217,7 @@ func (m BotUserMembers) IDs() []string {
return ids return ids
} }
// BotUser_QQConfig holds the admins and trusted groups for the QQ (Napcat) bot. // BotUser_QQConfig holds the admins and trusted groups for the QQ (OneBot) bot.
type BotUser_QQConfig struct { type BotUser_QQConfig struct {
Admins BotUserMembers `json:"admins"` Admins BotUserMembers `json:"admins"`
TrustedGroups BotUserMembers `json:"trustedGroups"` TrustedGroups BotUserMembers `json:"trustedGroups"`
@@ -232,7 +233,7 @@ type BotUser_TelegramConfig struct {
// and trusted groups (chat targets) for each bot channel separately from the // and trusted groups (chat targets) for each bot channel separately from the
// main config.json. // main config.json.
type BotUserConfig struct { type BotUserConfig struct {
QQ BotUser_QQConfig `json:"qq(napcat)"` QQ BotUser_QQConfig `json:"qq_onebot"`
Telegram BotUser_TelegramConfig `json:"telegram"` Telegram BotUser_TelegramConfig `json:"telegram"`
} }
@@ -257,13 +258,24 @@ type BotNodeOptions struct {
// bool) lets an absent field be told apart from an explicit false, and // bool) lets an absent field be told apart from an explicit false, and
// omitempty keeps untouched nodes stored as {}. // omitempty keeps untouched nodes stored as {}.
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"` EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
// FlapTolerant is the number of seconds a node's Offline notification is
// held in the hope the node returns. A node that comes back online within
// this window produces no notification at all; one still offline when the
// window elapses reports Offline as usual. Zero (or an absent field)
// disables inhibition, which keeps the notify-immediately behavior. It is
// a float64 rather than an int so a hand-written fractional value such as
// 2.5 parses instead of failing the whole config load at startup, and it
// is not a pointer because 0 already means "off", unlike
// EnableStatusNotify whose default is true.
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
} }
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node // BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
// options. // options.
type BotNodeMembers map[string]BotNodeOptions type BotNodeMembers map[string]BotNodeOptions
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig. // NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
// The map is rebuilt rather than mutated on every load, so the pointer can be // The map is rebuilt rather than mutated on every load, so the pointer can be
// swapped atomically; read it through BotNodes. // swapped atomically; read it through BotNodes.
var botNodeConfig atomic.Pointer[BotNodeMembers] var botNodeConfig atomic.Pointer[BotNodeMembers]
+1 -1
View File
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
## Features ## Features
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists). - **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes. - **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load. The pencil on a card opens a per-node options dialog holding the *status notify* switch and the *flap inhibition* window, persisted to `bot_node_config.json`. Searchable, auto-refreshes.
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`. - **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section. - **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export. - **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
-3
View File
@@ -1473,7 +1473,6 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1596,7 +1595,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -1671,7 +1669,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.42", "@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42", "@vue/compiler-sfc": "3.5.42",
+156 -20
View File
@@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js';
import { toast } from '../utils/toast.js'; import { toast } from '../utils/toast.js';
import { formatBytes, shortUUID } from '../utils/fmt.js'; import { formatBytes, shortUUID } from '../utils/fmt.js';
import Toggle from '../components/Toggle.vue'; import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue';
function percent(m) { function percent(m) {
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0; return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
@@ -13,9 +14,20 @@ const query = ref('');
const loading = ref(true); const loading = ref(true);
const failed = ref(false); const failed = ref(false);
const entries = ref([]); const entries = ref([]);
const saving = reactive(new Set());
let timer = null; let timer = null;
// The per-node options dialog. Its form is a copy of the node's settings taken
// when the dialog opens, so the 15s card refresh behind it cannot disturb an
// open editor, and closing without saving leaves the node untouched.
const editing = reactive({
open: false,
uuid: '',
name: '',
notify: true,
flap: '',
saving: false
});
const filtered = computed(() => { const filtered = computed(() => {
const q = query.value.trim().toLowerCase(); const q = query.value.trim().toLowerCase();
if (!q) return entries.value; if (!q) return entries.value;
@@ -57,7 +69,8 @@ async function load() {
online: !!s.online, online: !!s.online,
report: s.report || null, report: s.report || null,
info: infoEntry.info || null, info: infoEntry.info || null,
notify: conf.enableStatusNotify !== false notify: conf.enableStatusNotify !== false,
flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0
}); });
} }
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1)); list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
@@ -71,21 +84,42 @@ async function load() {
} }
} }
async function toggleNotify(entry) { function openOptions(entry) {
const next = !entry.notify; editing.uuid = entry.uuid;
const prev = entry.notify; editing.name = entry.name;
entry.notify = next; editing.notify = entry.notify;
saving.add(entry.uuid); editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : '';
editing.open = true;
}
// saveOptions writes both options in one patch. A flap window that is not a
// positive number disables inhibition, which is sent as a JSON null so the key
// is removed rather than stored as an explicit 0 — the file then keeps matching
// its documented "absent means off" default.
async function saveOptions() {
const typed = Number(editing.flap);
const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0;
const uuid = editing.uuid;
editing.saving = true;
try { try {
const patch = {}; const patch = {};
patch[entry.uuid] = { enableStatusNotify: next }; patch[uuid] = {
enableStatusNotify: editing.notify,
flap_tolerant: seconds > 0 ? seconds : null
};
await settingsApi.set('bot_node_config', patch); await settingsApi.set('bot_node_config', patch);
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`); const entry = entries.value.find((e) => e.uuid === uuid);
if (entry) {
entry.notify = editing.notify;
entry.flapTolerant = seconds;
}
toast.success(`${editing.name}: options saved`);
editing.open = false;
} catch (e) { } catch (e) {
entry.notify = prev;
toast.error('Save failed: ' + e.message); toast.error('Save failed: ' + e.message);
} finally { } finally {
saving.delete(entry.uuid); editing.saving = false;
} }
} }
@@ -111,7 +145,6 @@ onBeforeUnmount(() => {
<div> <div>
<p class="eyebrow">Overview</p> <p class="eyebrow">Overview</p>
<h1>Nodes</h1> <h1>Nodes</h1>
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load"> <button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
@@ -207,17 +240,60 @@ onBeforeUnmount(() => {
<span>·</span> <span>·</span>
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span> <span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
</div> </div>
<div class="notify"> <div class="opts">
<Toggle <span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
:model-value="e.notify" <i class="fas fa-bell-slash" /> Notify off
label="Status notify" </span>
:disabled="saving.has(e.uuid)" <span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
@update:model-value="toggleNotify(e)" <i class="fas fa-shield-halved" /> Flap {{ e.flapTolerant }}s
/> </span>
<button class="icon-btn" title="Node options" @click="openOptions(e)">
<i class="fas fa-pen" />
</button>
</div> </div>
</footer> </footer>
</article> </article>
</div> </div>
<Modal :open="editing.open" title="Node options" @close="editing.open = false">
<div class="node-head">
<p class="node-name">{{ editing.name }}</p>
<p class="node-uuid mono">{{ editing.uuid }}</p>
</div>
<div class="field">
<label>Status notify</label>
<Toggle v-model="editing.notify" label="Report online / offline changes" />
<p class="help">With this off, the node's status changes are not delivered to any pipes.</p>
</div>
<div class="field">
<label>Flap inhibition</label>
<div class="flap-row">
<input
v-model="editing.flap"
class="input flap-input"
type="number"
min="0"
max="86400"
step="1"
inputmode="numeric"
placeholder="0"
@keydown.enter="saveOptions"
/>
<span class="flap-unit">seconds</span>
</div>
<p class="help">How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.</p>
</div>
<template #foot>
<button class="btn btn-ghost" @click="editing.open = false">Cancel</button>
<button class="btn btn-primary" :disabled="editing.saving" @click="saveOptions">
<span v-if="editing.saving" class="spinner" style="width:12px;height:12px" />
<i v-else class="fas fa-check" /> Save
</button>
</template>
</Modal>
</section> </section>
</template> </template>
@@ -298,7 +374,67 @@ onBeforeUnmount(() => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.notify { display: flex; align-items: center; } .opts {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
/* Stay right-aligned even when the spec block is absent. */
margin-left: auto;
}
.opts .chip { white-space: nowrap; }
/* --- Node options dialog ------------------------------------------------ */
.node-head {
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.node-name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.node-uuid {
font-size: 11.5px;
color: var(--text-3);
margin-top: 3px;
overflow-wrap: anywhere;
}
/* The modal body has no grid to space stacked fields. */
.field + .field { margin-top: 20px; }
.flap-row {
display: flex;
align-items: center;
gap: 9px;
}
.flap-input {
width: 110px;
text-align: center;
font-variant-numeric: tabular-nums;
/* Match the plain hand-rolled field look: the rest of the console renders
numbers as ordinary inputs, so the spinner controls would stand out. */
-moz-appearance: textfield;
appearance: textfield;
}
.flap-input::-webkit-outer-spin-button,
.flap-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.flap-unit {
font-size: 12.5px;
color: var(--text-3);
}
.refresh-hint { .refresh-hint {
font-size: 12px; font-size: 12px;
+15 -15
View File
@@ -23,15 +23,16 @@ 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' }
] ]
}, },
{ {
@@ -60,16 +61,16 @@ const sections = [
] ]
}, },
{ {
id: 'qq', id: 'qq_onebot',
title: 'QQ controller (NapCat)', title: 'QQ controller (OneBot)',
root: ['controllerMethod', 'qq(napcat)'], root: ['controllerMethod', 'qq_onebot'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' }, { key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
{ key: 'napcatPort', type: 'text', label: 'NapCat port' }, { key: 'oneBotPort', type: 'text', label: 'OneBot port' },
{ key: 'napcatToken', type: 'password', label: 'NapCat token' }, { key: 'oneBotToken', type: 'password', label: 'OneBot token' },
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' }, { key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] } { key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
] ]
@@ -80,7 +81,7 @@ const sections = [
root: ['controllerMethod', 'telegram'], root: ['controllerMethod', 'telegram'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'botToken', type: 'password', label: 'Bot token' }, { key: 'botToken', type: 'password', label: 'Bot token' },
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] } { key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
@@ -92,7 +93,7 @@ const sections = [
root: ['controllerMethod', 'email'], root: ['controllerMethod', 'email'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'smtpHost', type: 'text', label: 'SMTP host' }, { key: 'smtpHost', type: 'text', label: 'SMTP host' },
{ key: 'smtpPort', type: 'number', label: 'SMTP port' }, { key: 'smtpPort', type: 'number', label: 'SMTP port' },
@@ -109,7 +110,7 @@ const sections = [
root: ['controllerMethod', 'ntfy'], root: ['controllerMethod', 'ntfy'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'server', type: 'text', label: 'Server' }, { key: 'server', type: 'text', label: 'Server' },
{ key: 'topic', type: 'text', label: 'Topic' }, { key: 'topic', type: 'text', label: 'Topic' },
@@ -124,7 +125,7 @@ const sections = [
root: ['controllerMethod', 'webhook'], root: ['controllerMethod', 'webhook'],
fields: [ fields: [
{ key: 'enabled', type: 'bool', label: 'Enabled' }, { key: 'enabled', type: 'bool', label: 'Enabled' },
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Format the alert body with code blocks and inline code in the posted payload.' }, { key: 'markdown', type: 'bool', label: 'Markdown' },
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' }, { key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
{ key: 'url', type: 'text', label: 'URL' }, { key: 'url', type: 'text', label: 'URL' },
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, { key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
@@ -170,7 +171,6 @@ onMounted(load);
<div> <div>
<p class="eyebrow">Configuration</p> <p class="eyebrow">Configuration</p>
<h1>System settings</h1> <h1>System settings</h1>
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button> <button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
+1 -1
View File
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
const CHANNELS = [ const CHANNELS = [
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' }, { key: 'qq_onebot', label: 'QQ (OneBot)', hint: 'Members are identified by QQ number / group number' },
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' } { key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
]; ];
+1 -1
View File
@@ -10,7 +10,7 @@ import Modal from '../components/Modal.vue';
// controller names in config.json (controllerMethod.*), which is exactly what // controller names in config.json (controllerMethod.*), which is exactly what
// the backend matches notifyPipes against. // the backend matches notifyPipes against.
const CHANNELS = [ const CHANNELS = [
{ key: 'qq(napcat)', label: 'QQ' }, { key: 'qq_onebot', label: 'QQ_OneBot' },
{ key: 'telegram', label: 'Telegram' }, { key: 'telegram', label: 'Telegram' },
{ key: 'email', label: 'Email' }, { key: 'email', label: 'Email' },
{ key: 'ntfy', label: 'ntfy' }, { key: 'ntfy', label: 'ntfy' },
+2 -2
View File
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
var SoftwareInfo = SoftwareInfoStr{ var SoftwareInfo = SoftwareInfoStr{
Name: "Nukumizu", Name: "Nukumizu",
Version: "0.2.1", Version: "0.2.2",
Developer: "Madobi Nanami", Developer: "Madobi Nanami",
BuildVer: 6, 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",
+176 -6
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"nukumizu-backend/config" "nukumizu-backend/config"
"nukumizu-backend/internal/node" "nukumizu-backend/internal/node"
@@ -27,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
@@ -100,13 +101,22 @@ 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
@@ -117,6 +127,19 @@ type Manager struct {
// from one that does not. It is read on the settings-update goroutine and // from one that does not. It is read on the settings-update goroutine and
// written when the set is replaced. // written when the set is replaced.
builtFrom atomic.Pointer[config.ControllerMethodConfig] 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
@@ -125,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")
} }
@@ -140,7 +164,7 @@ func GetManager() *Manager {
// //
// Controllers are rebuilt wholesale rather than reconfigured in place: each one // Controllers are rebuilt wholesale rather than reconfigured in place: each one
// reads its settings into fields at construction, and two of them own // reads its settings into fields at construction, and two of them own
// connections that cannot be re-pointed (the NapCat WebSocket listener is // connections that cannot be re-pointed (the OneBot WebSocket listener is
// stopped through a sync.Once, the Telegram polling context is created with the // stopped through a sync.Once, the Telegram polling context is created with the
// controller). Replacing the set keeps every channel on the same footing. // controller). Replacing the set keeps every channel on the same footing.
func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool { func (m *Manager) NeedsRebuild(next config.ControllerMethodConfig) bool {
@@ -183,7 +207,7 @@ func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodCo
} }
// Start off the calling goroutine, the way startup does: Telegram's getMe // Start off the calling goroutine, the way startup does: Telegram's getMe
// and the NapCat WebSocket handshake would otherwise hold the settings // and the OneBot WebSocket handshake would otherwise hold the settings
// request open for as long as they take. The new controllers are already // request open for as long as they take. The new controllers are already
// routable, and each one can send before Start returns. // routable, and each one can send before Start returns.
for _, ctrl := range next { for _, ctrl := range next {
@@ -201,7 +225,7 @@ func (m *Manager) ReplaceAll(next []Controller, method config.ControllerMethodCo
} }
// 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
@@ -268,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()
@@ -287,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
@@ -340,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 {
+225 -1
View File
@@ -1,6 +1,8 @@
package controller package controller
import ( import (
"os"
"path/filepath"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -79,6 +81,228 @@ func (m *Manager) routedTo(name string) Controller {
return m.controllers[name] 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) { func TestReplaceAllInstallsAndStarts(t *testing.T) {
m := newTestManager() m := newTestManager()
alpha, beta := newFake("alpha"), newFake("beta") alpha, beta := newFake("alpha"), newFake("beta")
@@ -97,7 +321,7 @@ func TestReplaceAllInstallsAndStarts(t *testing.T) {
} }
// TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the // TestReplaceAllStopsTheOutgoingSet is the invariant the rebuild relies on: the
// old controllers must be shut down, or a rebuilt NapCat or Telegram controller // old controllers must be shut down, or a rebuilt onebot or Telegram controller
// would leave its previous connection running. // would leave its previous connection running.
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) { func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
m := newTestManager() m := newTestManager()
@@ -1,330 +0,0 @@
package qq_napcat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
// actionLogEnabled reports whether the NapCat HTTP API methods echo each action
// they send, per the showNapcatAction toggle. Unlike the WebSocket message path
// in qq.go it deliberately does not also require debugMode: these methods have
// always logged on this toggle alone, and preserving that is intentional.
func actionLogEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowNapcatAction
}
// APIResponse mirrors NapCat's HTTP API response envelope.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// Client is the HTTP + WebSocket client for a single NapCat instance.
// It both listens for incoming OneBot events over WebSocket and issues
// outbound NapCat API calls over HTTP.
type Client struct {
addr string
port string
token string
useProxy bool
httpClient *http.Client
connMu sync.Mutex
conn *websocket.Conn
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a NapCat client for the given host/port/token. When
// useProxy is set, HTTP API calls and the WebSocket connection are routed
// through the system-wide network proxy.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
httpClient: netproxy.HTTPClient(useProxy, 30*time.Second),
stopCh: make(chan struct{}),
}
}
func (c *Client) baseURL() string {
return fmt.Sprintf("http://%s:%s", c.addr, c.port)
}
// sendRequest performs an HTTP call to NapCat, injecting access_token into POST
// bodies AND setting Authorization: Bearer (compatible with HTTP Server adapters
// created in the NapCat Web UI).
func (c *Client) sendRequest(method, endpoint string, body []byte) ([]byte, error) {
url := c.baseURL() + endpoint
// For POST requests, inject access_token into the JSON body before sending.
var finalBody []byte
if body != nil && c.token != "" {
var bodyMap map[string]interface{}
if err := json.Unmarshal(body, &bodyMap); err == nil {
bodyMap["access_token"] = c.token
finalBody, _ = json.Marshal(bodyMap)
}
}
if finalBody == nil {
finalBody = body
}
var req *http.Request
var err error
if finalBody != nil {
req, err = http.NewRequest(method, url, bytes.NewReader(finalBody))
} else {
req, err = http.NewRequest(method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request to napcat: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read napcat response: %w", err)
}
return respBody, nil
}
// parseResponse unmarshals NapCat's raw response body into an APIResponse.
func (c *Client) parseResponse(respBody []byte, caller string) (*APIResponse, error) {
var napcatResp APIResponse
if err := json.Unmarshal(respBody, &napcatResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal napcat response (%s): %w\n%s", caller, err, string(respBody))
}
return &napcatResp, nil
}
// SendMsg sends a message via NapCat.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var endpoint string
var napcatReq map[string]interface{}
switch targetType {
case "group":
endpoint = "/send_group_msg"
napcatReq = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
endpoint = "/send_private_msg"
napcatReq = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
reqBody, err := json.Marshal(napcatReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message))
}
respBody, err := c.sendRequest(http.MethodPost, endpoint, reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "SendMsg")
}
// RecallMsg recalls (deletes) a sent message via NapCat.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"message_id": msgID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID))
}
respBody, err := c.sendRequest(http.MethodPost, "/delete_msg", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "RecallMsg")
}
// GetGroupList retrieves the list of joined groups from NapCat.
func (c *Client) GetGroupList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[Napcat] GetGroupList -> /get_group_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_group_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupList")
}
// GetGroupInfo retrieves detailed information about a specific group from NapCat.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
reqBody, err := json.Marshal(map[string]interface{}{
"group_id": groupID,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID))
}
respBody, err := c.sendRequest(http.MethodPost, "/get_group_info", reqBody)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetGroupInfo")
}
// GetFriendsList retrieves the friends list from NapCat.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list")
}
respBody, err := c.sendRequest(http.MethodGet, "/get_friend_list", nil)
if err != nil {
return nil, err
}
return c.parseResponse(respBody, "GetFriendsList")
}
// Listen connects to the NapCat WebSocket server and calls onEvent for every raw
// message. It blocks forever, reconnecting every 5s after a disconnect, until
// Stop is called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[Napcat] WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(5 * time.Second):
}
}
}
// listenOnce connects to the NapCat WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
wsURL := fmt.Sprintf("ws://%s:%s/", c.addr, c.port)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info(fmt.Sprintf("[Napcat] Connected to NapCat WebSocket at %s", wsURL))
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[Napcat] WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// Stop closes any open WebSocket connection and unblocks the Listen reconnect loop.
func (c *Client) Stop() {
c.stopOnce.Do(func() { close(c.stopCh) })
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
}
@@ -0,0 +1,562 @@
package qq_onebot
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"nukumizu-backend/config"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/postLog"
)
const (
// onebotReconnectDelay is the pause between reconnect attempts, shared by the
// event socket and the API socket.
onebotReconnectDelay = 5 * time.Second
// onebotAPITimeout bounds one callAPI response wait. It matches the timeout the
// removed HTTP client (netproxy.HTTPClient(useProxy, 30s)) imposed, so callers
// see the same worst-case latency they always did.
onebotAPITimeout = 30 * time.Second
// onebotConnectWait bounds how long callAPI waits for the API socket to come up
// when it is not connected yet. It is deliberately short: the startup bot
// initialization message is sent on the main goroutine, and a down OneBot
// server must not stall HTTP-server startup for the full API timeout.
onebotConnectWait = 5 * time.Second
// onebotWriteTimeout bounds a single socket write so a wedged peer cannot block
// the caller (and, through writeMu, every other writer) indefinitely.
onebotWriteTimeout = 10 * time.Second
)
// actionLogEnabled reports whether the OneBot API methods echo each action they
// send, per the showOneBotAction toggle. Unlike the WebSocket message path in
// qq.go it deliberately does not also require debugMode: these methods have
// always logged on this toggle alone, and preserving that is intentional.
func actionLogEnabled() bool {
cfg := config.Current()
return cfg != nil && cfg.Debug.ShowOneBotAction
}
// APIResponse mirrors OneBot's API response envelope. Echo is the correlation id
// the client sent, echoed back by the server; callAPI uses it internally to match
// a response to its request. Data stays raw so callers decode get_group_list /
// get_friend_list arrays themselves.
type APIResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Echo json.RawMessage `json:"echo"`
}
// apiRequest is one OneBot API call written to the API socket. A struct rather
// than a map guarantees Echo is encoded as a JSON string, which is what the
// server sends back verbatim.
type apiRequest struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params"`
Echo string `json:"echo"`
}
// apiResult carries one decoded API response (or a transport error) from the API
// read loop back to the callAPI invocation waiting on its echo.
type apiResult struct {
resp *APIResponse
err error
}
// Client is the WebSocket client for a single OneBot instance. It uses two
// forward-WebSocket connections, per the OneBot 11 split between events and API
// calls:
//
// - the event socket ("/") only reads: one goroutine feeds every frame to
// onEvent. Nothing is ever written to it.
// - the API socket ("/api") only issues actions: callAPI writes a
// {"action","params","echo"} frame there, and its dedicated read loop matches
// the reply by echo.
//
// They must be separate: qq.go's event handler replies with SendMsg from inside
// the event read loop, so reading API responses on that same socket would
// deadlock (the loop would be blocked in the handler, waiting on a response only
// it could deliver).
type Client struct {
addr string
port string
token string
useProxy bool
// Event socket ("/"): one reader (listenOnce), closed only by Stop or its own
// cleanup, never written to.
connMu sync.Mutex
conn *websocket.Conn
// API socket ("/api"). apiMu guards the pointer, the epoch channel and the
// pending table; writeMu serializes the actual socket writes, so apiMu is
// never held across I/O (which would stall response dispatch behind a slow
// write).
apiMu sync.Mutex
apiConn *websocket.Conn
apiChanged chan struct{} // closed and replaced on every connect/disconnect
writeMu sync.Mutex
pending map[string]chan apiResult
echoSeq atomic.Uint64
stopped bool
stopCh chan struct{}
stopOnce sync.Once
}
// NewClient creates a OneBot client for the given host/port/token. When useProxy
// is set, both WebSocket connections are routed through the system-wide network
// proxy. It performs no I/O; Listen starts the sockets.
func NewClient(addr, port, token string, useProxy bool) *Client {
return &Client{
addr: addr,
port: port,
token: token,
useProxy: useProxy,
apiChanged: make(chan struct{}),
pending: make(map[string]chan apiResult),
stopCh: make(chan struct{}),
}
}
// dialWS opens one forward-WebSocket connection to the given path. The token is
// passed both as an access_token query parameter and as an Authorization: Bearer
// header (forward adapters check one or the other), and the dial goes through the
// system proxy when useProxy is set.
func (c *Client) dialWS(path string) (*websocket.Conn, error) {
wsURL := fmt.Sprintf("ws://%s:%s%s", c.addr, c.port, path)
header := http.Header{}
if c.token != "" {
wsURL += "?access_token=" + url.QueryEscape(c.token)
header.Set("Authorization", "Bearer "+c.token)
}
dialer := *websocket.DefaultDialer
dialer.Proxy = netproxy.ProxyFunc(c.useProxy)
conn, _, err := dialer.Dial(wsURL, header)
if err != nil {
return nil, fmt.Errorf("failed to connect to onebot websocket %s: %w", path, err)
}
return conn, nil
}
// SendMsg sends a message via OneBot.
// targetType: "group" or "private"
// targetID: QQ group ID or user ID
// msg: message content
// hasAt: whether to prepend an @mention
// atTargetID: the QQ ID to @mention
func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bool, atTargetID int64) (*APIResponse, error) {
// Build message with optional @mention.
message := msg
if hasAt && atTargetID > 0 {
message = fmt.Sprintf("[CQ:at,qq=%d] %s", atTargetID, msg)
}
var action string
var params map[string]interface{}
switch targetType {
case "group":
action = "send_group_msg"
params = map[string]interface{}{
"group_id": targetID,
"message": message,
}
case "private":
action = "send_private_msg"
params = map[string]interface{}{
"user_id": targetID,
"message": message,
}
default:
return nil, fmt.Errorf("invalid targetType: %s, must be 'group' or 'private'", targetType)
}
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] SendMsg -> %s (%s): %s", action, targetType, message))
}
return c.callAPI(action, params)
}
// RecallMsg recalls (deletes) a sent message via OneBot.
func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] RecallMsg -> delete_msg: %d", msgID))
}
return c.callAPI("delete_msg", map[string]interface{}{"message_id": msgID})
}
// GetGroupList retrieves the list of joined groups from OneBot.
func (c *Client) GetGroupList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetGroupList -> get_group_list")
}
return c.callAPI("get_group_list", nil)
}
// GetGroupInfo retrieves detailed information about a specific group from OneBot.
func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug(fmt.Sprintf("[qq_onebot] GetGroupInfo -> get_group_info: %d", groupID))
}
return c.callAPI("get_group_info", map[string]interface{}{"group_id": groupID})
}
// GetFriendsList retrieves the friends list from OneBot.
func (c *Client) GetFriendsList() (*APIResponse, error) {
if actionLogEnabled() {
postLog.Debug("[qq_onebot] GetFriendsList -> get_friend_list")
}
return c.callAPI("get_friend_list", nil)
}
// callAPI issues one OneBot action over the API socket and waits for its reply.
//
// It returns a Go error only for transport failures (not connected, write error,
// disconnect, timeout, Stop). An OneBot-level failure (status "failed", retcode
// 1xxx) is returned as an ordinary (*APIResponse, nil), matching the behavior of
// the HTTP client this replaces.
func (c *Client) callAPI(action string, params map[string]interface{}) (*APIResponse, error) {
// The spec allows omitting params entirely, but some adapters reject a null
// value, so a bodyless action still sends an empty object.
if params == nil {
params = map[string]interface{}{}
}
echo := strconv.FormatUint(c.echoSeq.Add(1), 10)
payload, err := json.Marshal(apiRequest{Action: action, Params: params, Echo: echo})
if err != nil {
return nil, fmt.Errorf("failed to marshal onebot request: %w", err)
}
// Buffered cap 1: the API read loop and failPending can each deliver at most
// once, and delivering must never block the read loop.
ch := make(chan apiResult, 1)
c.apiMu.Lock()
if c.stopped {
c.apiMu.Unlock()
return nil, fmt.Errorf("onebot client stopped")
}
c.pending[echo] = ch
c.apiMu.Unlock()
// Deregister on every exit path. dispatchAPI deletes the entry too, so this is
// a no-op when the response already arrived.
defer func() {
c.apiMu.Lock()
delete(c.pending, echo)
c.apiMu.Unlock()
}()
conn, err := c.waitForAPIConn()
if err != nil {
return nil, err
}
if err := c.writeAPI(conn, payload); err != nil {
return nil, fmt.Errorf("failed to send onebot request: %w", err)
}
timer := time.NewTimer(onebotAPITimeout)
defer timer.Stop()
select {
case res := <-ch:
return res.resp, res.err
case <-timer.C:
return nil, fmt.Errorf("onebot API %s timed out after %s", action, onebotAPITimeout)
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
// waitForAPIConn returns the live API connection, waiting (bounded by
// onebotConnectWait, or until Stop) for apiLoop to establish one when it is not
// connected yet. It re-checks the state on every apiChanged transition, so a
// closed channel left over from a previous connection cannot fool it.
func (c *Client) waitForAPIConn() (*websocket.Conn, error) {
timer := time.NewTimer(onebotConnectWait)
defer timer.Stop()
for {
c.apiMu.Lock()
conn := c.apiConn
changed := c.apiChanged
stopped := c.stopped
c.apiMu.Unlock()
if conn != nil {
return conn, nil
}
if stopped {
return nil, fmt.Errorf("onebot client stopped")
}
select {
case <-changed:
// Connection state changed; re-check.
case <-timer.C:
return nil, fmt.Errorf("onebot API WebSocket not connected")
case <-c.stopCh:
return nil, fmt.Errorf("onebot client stopped")
}
}
}
// writeAPI serializes one request frame onto conn under writeMu, with a write
// deadline. gorilla/websocket panics on concurrent writes, and both the event
// handler and every SendMessage path call callAPI, so concurrency is the norm.
func (c *Client) writeAPI(conn *websocket.Conn, payload []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(onebotWriteTimeout)); err != nil {
return err
}
return conn.WriteMessage(websocket.TextMessage, payload)
}
// Listen connects the event WebSocket and calls onEvent for every raw message. It
// starts the API socket loop as well, since NewClient is side-effect-free and
// Listen is the single entry point the controller drives. It blocks forever,
// reconnecting every onebotReconnectDelay after a disconnect, until Stop is
// called. Run it in a goroutine.
func (c *Client) Listen(onEvent func(raw []byte)) {
go c.apiLoop()
for {
select {
case <-c.stopCh:
return
default:
}
c.listenOnce(onEvent)
postLog.Warning("[qq_onebot] Event WebSocket disconnected, reconnecting in 5s...")
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// listenOnce connects to the OneBot event WebSocket and reads events until the
// connection drops or Stop is called.
func (c *Client) listenOnce(onEvent func(raw []byte)) {
conn, err := c.dialWS("/")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot event WebSocket: %v", err))
return
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer func() {
c.connMu.Lock()
if c.conn == conn {
c.conn = nil
}
c.connMu.Unlock()
conn.Close()
}()
postLog.Info("[qq_onebot] Connected to OneBot event WebSocket")
for {
select {
case <-c.stopCh:
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Event WebSocket read error: %v", err))
return
}
onEvent(msgBytes)
}
}
// apiLoop owns the API socket: it keeps a connection to "/api" up, dispatching
// any reply its read loop receives and reconnecting every onebotReconnectDelay.
// It exits on Stop.
func (c *Client) apiLoop() {
for {
select {
case <-c.stopCh:
return
default:
}
c.runAPIConnection()
select {
case <-c.stopCh:
return
case <-time.After(onebotReconnectDelay):
}
}
}
// runAPIConnection dials "/api", publishes the connection to waiters, and reads
// response frames until the connection drops or Stop is called. On the way out it
// marks the socket down (waking waiters and failing in-flight calls) so a caller
// does not wait the full timeout for a reply that can no longer arrive.
func (c *Client) runAPIConnection() {
conn, err := c.dialWS("/api")
if err != nil {
postLog.Error(fmt.Sprintf("[qq_onebot] Failed to connect to OneBot API WebSocket: %v", err))
return
}
c.apiMu.Lock()
c.apiConn = conn
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
postLog.Info("[qq_onebot] Connected to OneBot API WebSocket")
c.readAPILoop(conn)
c.apiMu.Lock()
if c.apiConn == conn {
c.apiConn = nil
}
close(c.apiChanged)
c.apiChanged = make(chan struct{})
c.apiMu.Unlock()
conn.Close()
c.failPending(fmt.Errorf("onebot API WebSocket disconnected"))
}
// readAPILoop reads response frames from the API socket until the connection
// drops or Stop is called. It is the only reader of that connection.
func (c *Client) readAPILoop(conn *websocket.Conn) {
for {
select {
case <-c.stopCh:
return
default:
}
_, data, err := conn.ReadMessage()
if err != nil {
select {
case <-c.stopCh:
// Stopping: the read error is just the closed socket.
default:
postLog.Error(fmt.Sprintf("[qq_onebot] API WebSocket read error: %v", err))
}
return
}
c.dispatchAPI(data)
}
}
// dispatchAPI decodes one inbound frame on the API socket. A frame with no usable
// echo is not an API response and is ignored (the event socket is the only place
// events are expected); otherwise the pending channel registered for that echo
// receives the envelope and is removed, both under apiMu.
func (c *Client) dispatchAPI(raw []byte) {
var resp APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
postLog.Debug("[qq_onebot] Failed to parse OneBot API response: " + err.Error())
return
}
key, ok := echoKey(resp.Echo)
if !ok {
postLog.Debug("[qq_onebot] Ignoring non-response frame on OneBot API WebSocket: " + string(raw))
return
}
c.apiMu.Lock()
ch := c.pending[key]
delete(c.pending, key)
c.apiMu.Unlock()
if ch != nil {
ch <- apiResult{resp: &resp}
}
}
// echoKey normalizes an echo value to its pending-map key. An absent or null echo
// yields ok=false, i.e. the frame is not an API response. A JSON string is
// unquoted; any other type falls back to its verbatim bytes so an adapter that
// returns the id unquoted still matches.
func echoKey(raw json.RawMessage) (string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, true
}
return string(raw), true
}
// failPending delivers err to every call waiting on a response and clears the
// table, so a dropped API socket unblocks its callers immediately. Called on
// disconnect and by Stop; safe to call repeatedly.
func (c *Client) failPending(err error) {
c.apiMu.Lock()
pending := c.pending
c.pending = make(map[string]chan apiResult)
c.apiMu.Unlock()
for _, ch := range pending {
// Buffered cap 1, and each entry is owned exactly once, so this never blocks.
ch <- apiResult{err: err}
}
}
// Stop closes both WebSocket connections and unblocks the event reconnect loop,
// the API loop and every in-flight callAPI. It is idempotent and safe to call
// even when Listen was never started.
func (c *Client) Stop() {
c.stopOnce.Do(func() {
close(c.stopCh)
c.apiMu.Lock()
c.stopped = true
apiConn := c.apiConn
c.apiMu.Unlock()
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
if apiConn != nil {
apiConn.Close()
}
c.failPending(fmt.Errorf("onebot client stopped"))
})
}
@@ -0,0 +1,235 @@
package qq_onebot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeOneBot is a minimal OneBot 11 forward-WebSocket server. "/api" answers
// every action call by echoing its echo with a fixed ok response; "/" accepts the
// event socket and hands it to the test so events can be pushed on demand.
type fakeOneBot struct {
upgrader websocket.Upgrader
server *httptest.Server
apiRequests chan fakeAPIRequest
eventConns chan *websocket.Conn
}
type fakeAPIRequest struct {
Action string `json:"action"`
Params json.RawMessage `json:"params"`
Echo json.RawMessage `json:"echo"`
}
func newFakeOneBot(t *testing.T) *fakeOneBot {
t.Helper()
f := &fakeOneBot{
apiRequests: make(chan fakeAPIRequest, 16),
eventConns: make(chan *websocket.Conn, 16),
}
mux := http.NewServeMux()
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var req fakeAPIRequest
if err := json.Unmarshal(data, &req); err != nil {
continue
}
f.apiRequests <- req
resp, _ := json.Marshal(map[string]json.RawMessage{
"status": json.RawMessage(`"ok"`),
"retcode": json.RawMessage(`0`),
"data": json.RawMessage(`{"message_id":123}`),
"echo": req.Echo,
})
if err := conn.WriteMessage(websocket.TextMessage, resp); err != nil {
return
}
}
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
f.eventConns <- conn
// Keep the event socket open until the client goes away.
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
// client returns a Client pointed at this fake server with the proxy disabled.
func (f *fakeOneBot) client(t *testing.T) *Client {
t.Helper()
u, err := url.Parse(f.server.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
return NewClient(u.Hostname(), u.Port(), "", false)
}
// eventConn waits for the event socket to be established and returns it.
func (f *fakeOneBot) eventConn(t *testing.T) *websocket.Conn {
t.Helper()
select {
case conn := <-f.eventConns:
return conn
case <-time.After(3 * time.Second):
t.Fatal("event WebSocket never connected")
return nil
}
}
// TestSendMsgOverWebSocket checks an API call is issued over the "/api" socket
// and its response is decoded.
func TestSendMsgOverWebSocket(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
resp, err := c.SendMsg("group", 12345, "hello", false, 0)
if err != nil {
t.Fatalf("SendMsg: %v", err)
}
if resp.Status != "ok" || resp.RetCode != 0 {
t.Fatalf("unexpected response: %+v", resp)
}
var data struct {
MessageID int64 `json:"message_id"`
}
if err := json.Unmarshal(resp.Data, &data); err != nil {
t.Fatalf("decode data: %v", err)
}
if data.MessageID != 123 {
t.Fatalf("message_id = %d, want 123", data.MessageID)
}
req := <-f.apiRequests
if req.Action != "send_group_msg" {
t.Fatalf("action = %q, want send_group_msg", req.Action)
}
var params map[string]any
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params["group_id"].(float64) != 12345 || params["message"].(string) != "hello" {
t.Fatalf("params = %v", params)
}
var echo string
if err := json.Unmarshal(req.Echo, &echo); err != nil {
t.Fatalf("echo is not a JSON string: %s", req.Echo)
}
}
// TestBodylessActionSendsEmptyParams checks actions without arguments still send
// a params object (never null), which some adapters reject.
func TestBodylessActionSendsEmptyParams(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
go c.Listen(func([]byte) {})
defer c.Stop()
if _, err := c.GetGroupList(); err != nil {
t.Fatalf("GetGroupList: %v", err)
}
req := <-f.apiRequests
if req.Action != "get_group_list" {
t.Fatalf("action = %q, want get_group_list", req.Action)
}
if string(req.Params) != "{}" {
t.Fatalf("params = %s, want {}", req.Params)
}
}
// TestSendMsgFromEventCallbackDoesNotDeadlock is the regression test for the
// reason the client uses two sockets: the event handler replies with SendMsg from
// inside the event read loop, so a response read on that same connection would
// never arrive.
func TestSendMsgFromEventCallbackDoesNotDeadlock(t *testing.T) {
f := newFakeOneBot(t)
c := f.client(t)
type result struct {
resp *APIResponse
err error
}
done := make(chan result, 1)
go c.Listen(func(raw []byte) {
resp, err := c.SendMsg("group", 1, "pong", false, 0)
done <- result{resp, err}
})
defer c.Stop()
evConn := f.eventConn(t)
event := []byte(`{"post_type":"message","message_type":"group","group_id":1,"user_id":2,"raw_message":"/ping","self_id":9}`)
if err := evConn.WriteMessage(websocket.TextMessage, event); err != nil {
t.Fatalf("write event: %v", err)
}
select {
case r := <-done:
if r.err != nil {
t.Fatalf("SendMsg from event callback: %v", r.err)
}
if r.resp.Status != "ok" {
t.Fatalf("unexpected response: %+v", r.resp)
}
case <-time.After(5 * time.Second):
t.Fatal("deadlock: SendMsg did not return from within the event callback")
}
}
// TestEchoKey covers the response-matcher: an absent or null echo is not a
// response, and both string and non-string echoes normalize to a key.
func TestEchoKey(t *testing.T) {
cases := []struct {
in string
key string
ok bool
}{
{`"42"`, "42", true},
{`42`, "42", true},
{`null`, "", false},
{``, "", false},
}
for _, tc := range cases {
key, ok := echoKey(json.RawMessage(tc.in))
if key != tc.key || ok != tc.ok {
t.Errorf("echoKey(%q) = (%q, %v), want (%q, %v)", tc.in, key, ok, tc.key, tc.ok)
}
}
}
@@ -1,4 +1,4 @@
package qq_napcat package qq_onebot
import ( import (
"encoding/json" "encoding/json"
@@ -12,7 +12,7 @@ import (
"nukumizu-backend/postLog" "nukumizu-backend/postLog"
) )
// oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket. // oneBotEvent mirrors a OneBot 11 event pushed over the OneBot WebSocket.
// Only "message" events are handled; notice/request/meta_event are ignored. // Only "message" events are handled; notice/request/meta_event are ignored.
type oneBotEvent struct { type oneBotEvent struct {
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event" PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
@@ -29,45 +29,46 @@ type oneBotEvent struct {
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc. SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
} }
// QQController handles QQ Bot interactions by connecting directly to NapCat. // QQController handles QQ Bot interactions by connecting directly to OneBot.
type QQController struct { type QQController struct {
cfg config.QQConfig cfg config.OneBotConfig
napcatClient *Client onebotClient *Client
} }
// NewQQController creates a new QQ (Napcat) controller. // NewQQController creates a new QQ (OneBot) controller.
func NewQQController(cfg config.QQConfig) *QQController { func NewQQController(cfg config.OneBotConfig) *QQController {
q := &QQController{cfg: cfg} q := &QQController{cfg: cfg}
if cfg.Enabled { if cfg.Enabled {
q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy) q.onebotClient = NewClient(cfg.OneBotAddr, cfg.OneBotPort, cfg.OneBotToken, cfg.NetworkUseProxy)
} }
return q return q
} }
// Name returns the controller name. // Name returns the controller name.
func (q *QQController) Name() string { func (q *QQController) Name() string {
return "qq(napcat)" return "qq_onebot"
} }
// Start initializes the QQ controller and starts the NapCat WebSocket listener. // Start initializes the QQ controller and starts the OneBot WebSocket listeners
// (the event socket and the API socket the client manages internally).
func (q *QQController) Start() error { func (q *QQController) Start() error {
if !q.cfg.Enabled { if !q.cfg.Enabled {
// postLog.Info("QQ (Napcat) controller is disabled") // postLog.Info("QQ (Napcat) controller is disabled")
return nil return nil
} }
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil") postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is nil")
return nil return nil
} }
postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...") postLog.Info("[qq_onebot] QQ (OneBot) controller started, connecting to OneBot WebSocket...")
go func() { go func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r)) postLog.Error("[qq_onebot] OneBot WS listener panic recovered: " + fmt.Sprintf("%v", r))
} }
}() }()
q.napcatClient.Listen(q.handleNapcatEvent) q.onebotClient.Listen(q.handleOneBotEvent)
}() }()
return nil return nil
@@ -75,10 +76,10 @@ func (q *QQController) Start() error {
// Stop shuts down the QQ controller and its WebSocket listener. // Stop shuts down the QQ controller and its WebSocket listener.
func (q *QQController) Stop() { func (q *QQController) Stop() {
if q.napcatClient != nil { if q.onebotClient != nil {
q.napcatClient.Stop() q.onebotClient.Stop()
} }
postLog.Info("QQ (Napcat) controller stopped") postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
} }
// IsEnabled returns whether the controller is enabled. // IsEnabled returns whether the controller is enabled.
@@ -92,11 +93,11 @@ func (q *QQController) IsMarkdown() bool {
return q.cfg.Markdown return q.cfg.Markdown
} }
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket. // handleOneBotEvent processes a raw OneBot event received from the OneBot WebSocket.
func (q *QQController) handleNapcatEvent(raw []byte) { func (q *QQController) handleOneBotEvent(raw []byte) {
var ev oneBotEvent var ev oneBotEvent
if err := json.Unmarshal(raw, &ev); err != nil { if err := json.Unmarshal(raw, &ev); err != nil {
postLog.Debug("Failed to parse NapCat WS event: " + err.Error()) postLog.Debug("[qq_onebot] Failed to parse OneBot WS event: " + err.Error())
return return
} }
@@ -108,16 +109,16 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
return return
} }
debugMode := cfg.System.DebugMode debugMode := cfg.System.DebugMode
showAction := debugMode && cfg.Debug.ShowNapcatAction showAction := debugMode && cfg.Debug.ShowOneBotAction
if debugMode && cfg.Debug.ShowNapcatMsg { if debugMode && cfg.Debug.ShowOneBotMsg {
postLog.Debug("Napcat WS event received: " + string(raw)) postLog.Debug("[qq_onebot] OneBot WS event received: " + string(raw))
} }
// Only handle message events; ignore notice/request/meta_event. // Only handle message events; ignore notice/request/meta_event.
if ev.PostType != "message" { if ev.PostType != "message" {
if showAction { if showAction {
postLog.Debug("Ignoring Napcat WS event: " + string(raw)) postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
} }
return return
} }
@@ -126,9 +127,9 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
if q.isSelfMessage(ev) && !debugMode { if q.isSelfMessage(ev) && !debugMode {
return return
} }
if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg { if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
if showAction { if showAction {
postLog.Debug("Ignoring Napcat WS self message: " + string(raw)) postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
} }
return return
} }
@@ -149,19 +150,19 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
response := q.processCommand(cmd) response := q.processCommand(cmd)
if response == "" { if response == "" {
if showAction { if showAction {
postLog.Debug("Napcat WS command discarded: " + string(raw)) postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
} }
return return
} }
// Reply in the same chat. // Reply in the same chat.
if ev.MessageType == "private" { if ev.MessageType == "private" {
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat private reply: " + err.Error()) postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
} }
} else { } else {
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
postLog.Warning("Failed to send NapCat group reply: " + err.Error()) postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
} }
} }
} }
@@ -179,8 +180,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
if q.cfg.ListenMethod == "at" { if q.cfg.ListenMethod == "at" {
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID) atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
if !strings.Contains(text, atMention) { if !strings.Contains(text, atMention) {
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction { if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
postLog.Debug("Napcat WS message ignored (no @mention): " + text) postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
} }
return "" // Not mentioned, ignore. return "" // Not mentioned, ignore.
} }
@@ -202,12 +203,13 @@ func (q *QQController) processCommand(cmd controller.Command) string {
// vs private, trusted groups, admin permissions, and executes it. // vs private, trusted groups, admin permissions, and executes it.
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod) response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho { if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID)) postLog.Debug(fmt.Sprintf("[qq_onebot] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
} }
if err != nil { if err != nil {
postLog.Error("Napcat command processing failed: " + err.Error()) postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
return "" return ""
} }
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
return response return response
} }
@@ -341,35 +343,35 @@ func (q *QQController) SendAlert(alert controller.Alert) error {
} }
func (q *QQController) sendGroupMessage(groupID string, message string) { func (q *QQController) sendGroupMessage(groupID string, message string) {
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("Cannot send QQ group message: NapCat client not initialized") postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
return return
} }
var groupIDInt int64 var groupIDInt int64
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 { if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID)) postLog.Warning("[qq_onebot] Invalid QQ group ID: " + groupID)
return return
} }
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err)) postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
} }
} }
func (q *QQController) sendPrivateMessage(userID string, message string) { func (q *QQController) sendPrivateMessage(userID string, message string) {
if q.napcatClient == nil { if q.onebotClient == nil {
postLog.Warning("Cannot send QQ private message: NapCat client not initialized") postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
return return
} }
var userIDInt int64 var userIDInt int64
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 { if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID)) postLog.Warning("[qq_onebot] Invalid QQ user ID: " + userID)
return return
} }
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil { if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err)) postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
} }
} }
+5
View File
@@ -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
} }
+4 -4
View File
@@ -14,7 +14,7 @@ import (
"nukumizu-backend/global" "nukumizu-backend/global"
"nukumizu-backend/internal/controller" "nukumizu-backend/internal/controller"
"nukumizu-backend/internal/controller/pipes" "nukumizu-backend/internal/controller/pipes"
"nukumizu-backend/internal/controller/pipes/qq_napcat" "nukumizu-backend/internal/controller/pipes/qq_onebot"
"nukumizu-backend/internal/controller/pipes/telegram" "nukumizu-backend/internal/controller/pipes/telegram"
"nukumizu-backend/internal/komari" "nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node" "nukumizu-backend/internal/node"
@@ -72,7 +72,7 @@ func main() {
} }
// Controller settings changed. Each controller reads its settings into // Controller settings changed. Each controller reads its settings into
// fields when it is built, and the NapCat and Telegram ones own // fields when it is built, and the OneBot and Telegram ones own
// connections that cannot be re-pointed, so the change is applied by // connections that cannot be re-pointed, so the change is applied by
// replacing the whole set rather than reconfiguring it in place. // replacing the whole set rather than reconfiguring it in place.
postLog.Info("Controller settings changed; rebuilding every channel") postLog.Info("Controller settings changed; rebuilding every channel")
@@ -131,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 {
@@ -226,7 +226,7 @@ func startWebhookServer(cfg *config.Config) {
// own connections that cannot be re-pointed. // own connections that cannot be re-pointed.
func buildControllers(cfg *config.Config) []controller.Controller { func buildControllers(cfg *config.Config) []controller.Controller {
return []controller.Controller{ return []controller.Controller{
qq_napcat.NewQQController(cfg.ControllerMethod.QQ), qq_onebot.NewQQController(cfg.ControllerMethod.QQ_OneBot),
telegram.NewTelegramController(cfg.ControllerMethod.Telegram), telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
pipes.NewEmailController(cfg.ControllerMethod.Email), pipes.NewEmailController(cfg.ControllerMethod.Email),
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy), pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
#
# Runs the server from source, with the build metadata stamped in.
#
# The console is embedded in the binary (web/embed.go), so compiling without
# web/dist fails. Say that plainly rather than leaving go:embed's error.
set -euo pipefail
# Run from the repository root, however the script was invoked.
cd "$(dirname "$0")"
if [ ! -f web/dist/index.html ]; then
echo "The web console is not built: web/dist is missing." >&2
echo "Run build-linux-x86_64.sh once, or \"npm run build\" in frontend/." >&2
exit 1
fi
# Get git commit hash (shortened to 7 characters, can also use full)
COMMIT=$(git rev-parse --short HEAD)
# Get UTC time
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build -ldflags
LDFLAGS="-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT"
go run -ldflags "$LDFLAGS" .