Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2159ff0c | ||
|
|
bc5cb417eb | ||
|
|
1a7a4cdc07 | ||
|
|
9137d53286 | ||
|
|
03e74f49c3 | ||
|
|
0c581abc4c | ||
|
|
ba0d8539b3 | ||
|
|
1b11be2427 | ||
|
|
72d0d587cb | ||
|
|
c5a80bd952 |
No files matched your search
@@ -77,9 +77,9 @@ nukumizu-backend/
|
||||
│ ├── email.go # Email notification pipe
|
||||
│ ├── ntfy.go # ntfy notification pipe
|
||||
│ ├── webhook.go # Outgoing webhook notification pipe
|
||||
│ ├── qq_napcat/
|
||||
│ ├── qq_onebot/
|
||||
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||
│ │ └── onebot.go # OneBot 11 WebSocket client (events + API)
|
||||
│ └── telegram/
|
||||
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
||||
@@ -101,7 +101,7 @@ nukumizu-backend/
|
||||
- Go **1.25** or newer
|
||||
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
|
||||
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint
|
||||
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 forward WebSocket endpoint (`/api` and `/event`)
|
||||
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
||||
|
||||
Key dependencies: `github.com/go-telegram/bot`, `github.com/gorilla/websocket`, `gopkg.in/mail.v2`, `modernc.org/sqlite`.
|
||||
@@ -218,7 +218,7 @@ There are two configuration files, both read from the working directory unless o
|
||||
|
||||
Field notes:
|
||||
|
||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, NapCat HTTP/WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||
- `system.networkProxy` is a **system-wide** proxy URL. A controller only uses it when its own `networkUseProxy` is `true`. Applied to Telegram HTTP polling, QQ OneBot WebSocket, ntfy and webhook requests, and Email SMTP (tunneled via HTTP CONNECT).
|
||||
- `webhook` configures the **incoming** webhook API (see [Incoming webhook API](#incoming-webhook-api)); `controllerMethod.webhook` configures the outgoing webhook notification channel. They are independent.
|
||||
- `markdown` is a per-channel switch on all five channels. With it `false` (the default) every rendered value is inserted as plain text; with it `true` the values meant to be read verbatim (UUIDs, event messages, commands, command results, alert source and alert content) are wrapped in Markdown code spans / fenced blocks. Nothing is inferred from the channel name, so a channel only ever gets the formatting you asked for — turn it off for a channel whose platform does not render Markdown. On Telegram it also picks the `parse_mode`: with `markdown` off, messages are sent without one, so text containing `*` or `_` is delivered as-is rather than rejected by the API as malformed Markdown.
|
||||
- `controllerMethod.qq(napcat).listenMethod` / `telegram.listenMethod` — see [Bot recognition modes](#bot-recognition-modes).
|
||||
@@ -387,7 +387,7 @@ Status codes: `200` delivered, `400` malformed body or empty `subject`/`content`
|
||||
|
||||
## Bots
|
||||
|
||||
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 (WebSocket event stream + HTTP actions); Telegram uses `go-telegram/bot` long polling.
|
||||
QQ (NapCat) and Telegram bots share one command engine and authorization pipeline, implemented in `internal/controller/`. NapCat speaks OneBot 11 over WebSocket (event stream + actions); Telegram uses `go-telegram/bot` long polling.
|
||||
|
||||
### Permission model
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
## Ver.0.2.1.6-
|
||||
## Ver.0.2.1.6-d06d0bc.pre-release
|
||||
### Features
|
||||
- Most of settings now support hot reload when changed
|
||||
- [Network proxy]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/457ea424178b397f5509578d6180da437aaed075")
|
||||
|
||||
+33
-10
@@ -5,13 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||
// other config files this one is auto-generated and optional: a missing or
|
||||
// empty file yields an empty map, so every node falls back to its default
|
||||
// enableStatusNotify value (true).
|
||||
// empty file yields an empty map, so every node falls back to its defaults —
|
||||
// enableStatusNotify true and flap_tolerant 0 (no flap inhibition).
|
||||
func LoadBotNodeConfig(configPath string) error {
|
||||
cfg := BotNodeMembers{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
@@ -44,6 +45,27 @@ func NodeStatusNotifyEnabled(uuid string) bool {
|
||||
return *opts.EnableStatusNotify
|
||||
}
|
||||
|
||||
// maxFlapTolerantSeconds bounds the flap-inhibition window so an absurd
|
||||
// hand-written value cannot overflow time.Duration.
|
||||
const maxFlapTolerantSeconds = 86400
|
||||
|
||||
// NodeFlapTolerant returns how long an Offline notification for uuid should be
|
||||
// held in order to absorb a quick reconnect, per bot_node_config.json's
|
||||
// flap_tolerant. It returns 0 (inhibition disabled) when the node has no entry,
|
||||
// the value is zero or negative, or the config has not been loaded — in every
|
||||
// case the caller falls back to notifying immediately.
|
||||
func NodeFlapTolerant(uuid string) time.Duration {
|
||||
opts, ok := BotNodes()[uuid]
|
||||
if !ok || opts.FlapTolerant <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := opts.FlapTolerant
|
||||
if seconds > maxFlapTolerantSeconds {
|
||||
seconds = maxFlapTolerantSeconds
|
||||
}
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
|
||||
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||
// and stores it as a global singleton.
|
||||
func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
@@ -65,15 +87,15 @@ func LoadGlobalConfig(configPath string) (*Config, error) {
|
||||
cfg.System.ListenPort = "8080"
|
||||
}
|
||||
|
||||
// Apply defaults for QQ controller.
|
||||
if cfg.ControllerMethod.QQ.ListenMethod == "" {
|
||||
cfg.ControllerMethod.QQ.ListenMethod = "global"
|
||||
// Apply defaults for QQ_OneBot controller.
|
||||
if cfg.ControllerMethod.QQ_OneBot.ListenMethod == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.ListenMethod = "global"
|
||||
}
|
||||
if cfg.ControllerMethod.QQ.NapcatAddr == "" {
|
||||
cfg.ControllerMethod.QQ.NapcatAddr = "127.0.0.1"
|
||||
if cfg.ControllerMethod.QQ_OneBot.OneBotAddr == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.OneBotAddr = "127.0.0.1"
|
||||
}
|
||||
if cfg.ControllerMethod.QQ.NapcatPort == "" {
|
||||
cfg.ControllerMethod.QQ.NapcatPort = "3000"
|
||||
if cfg.ControllerMethod.QQ_OneBot.OneBotPort == "" {
|
||||
cfg.ControllerMethod.QQ_OneBot.OneBotPort = "3000"
|
||||
}
|
||||
|
||||
// Apply defaults for Telegram controller.
|
||||
@@ -168,7 +190,8 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
||||
// which is a plain registry of the nodes Komari reports (read back at startup
|
||||
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
||||
// any per-node option a user set by hand — enableStatusNotify or flap_tolerant
|
||||
// — is left untouched. UUIDs seen for
|
||||
// the first time are added as an empty object, i.e. no parameter is written, so
|
||||
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||
// its object keys emitted in sorted order by encoding/json, keeping the file
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// TestNodeFlapTolerant covers the flap-inhibition window accessor against the
|
||||
// per-node options in bot_node_config.json, including the hand-written values
|
||||
// the comment on BotNodeOptions.FlapTolerant promises to accept.
|
||||
func TestNodeFlapTolerant(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
|
||||
"seconds": { "flap_tolerant": 30 },
|
||||
"fractional": { "flap_tolerant": 2.5 },
|
||||
"zero": { "flap_tolerant": 0 },
|
||||
"negative": { "flap_tolerant": -5 },
|
||||
"unset": { "enableStatusNotify": true },
|
||||
"absurd": { "flap_tolerant": 1e9 },
|
||||
"throughput": { "flap_tolerant": 8, "enableStatusNotify": false }
|
||||
}`)
|
||||
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
uuid string
|
||||
want time.Duration
|
||||
}{
|
||||
{"whole seconds", "seconds", 30 * time.Second},
|
||||
{"fractional seconds parse", "fractional", 2500 * time.Millisecond},
|
||||
{"zero disables", "zero", 0},
|
||||
{"negative disables", "negative", 0},
|
||||
{"absent field disables", "unset", 0},
|
||||
{"unknown node disables", "not-in-the-file", 0},
|
||||
{"absurd value is clamped", "absurd", maxFlapTolerantSeconds * time.Second},
|
||||
{"independent of enableStatusNotify", "throughput", 8 * time.Second},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := NodeFlapTolerant(tc.uuid); got != tc.want {
|
||||
t.Errorf("NodeFlapTolerant(%q) = %v, want %v", tc.uuid, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeFlapTolerantWithoutConfig pins the nil-safe default: before any
|
||||
// bot_node_config.json has been loaded — and for a file that is missing or
|
||||
// empty — no node has a window, so notifications keep their previous
|
||||
// notify-immediately behavior.
|
||||
func TestNodeFlapTolerantWithoutConfig(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, ``)
|
||||
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
|
||||
if got := NodeFlapTolerant("anything"); got != 0 {
|
||||
t.Errorf("node flap tolerance from an empty file = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// nodeEntry reads one node's raw object back from the settings file, the way
|
||||
// GetSettings serves it to the console.
|
||||
func nodeEntry(t *testing.T, uuid string) map[string]interface{} {
|
||||
t.Helper()
|
||||
data, err := GetSettings(SettingBotNodeConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
var all map[string]map[string]interface{}
|
||||
if err := json.Unmarshal(data, &all); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
return all[uuid]
|
||||
}
|
||||
|
||||
// TestFlapTolerantSettingsRoundTrip drives the payload the console's node card
|
||||
// actually posts — { "<uuid>": { "flap_tolerant": <seconds> } } — through the
|
||||
// settings API, so the frontend and the accessor are checked against each other
|
||||
// and not just against hand-written files.
|
||||
func TestFlapTolerantSettingsRoundTrip(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
|
||||
"node-a": { "enableStatusNotify": false }
|
||||
}`)
|
||||
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"node-a": map[string]interface{}{"flap_tolerant": 30},
|
||||
}
|
||||
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if got := NodeFlapTolerant("node-a"); got != 30*time.Second {
|
||||
t.Errorf("NodeFlapTolerant after save = %v, want 30s", got)
|
||||
}
|
||||
// The sibling option must survive: the card saves only the field it edits.
|
||||
entry := nodeEntry(t, "node-a")
|
||||
if entry["enableStatusNotify"] != false {
|
||||
t.Errorf("enableStatusNotify = %v, want it preserved as false", entry["enableStatusNotify"])
|
||||
}
|
||||
if entry["flap_tolerant"] != float64(30) {
|
||||
t.Errorf("flap_tolerant on disk = %v, want 30", entry["flap_tolerant"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapTolerantSettingsRemoval: clearing the console's field sends a JSON
|
||||
// null, which must remove the key rather than leave an explicit 0 behind.
|
||||
func TestFlapTolerantSettingsRemoval(t *testing.T) {
|
||||
writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{
|
||||
"node-a": { "enableStatusNotify": true, "flap_tolerant": 30 }
|
||||
}`)
|
||||
if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"node-a": map[string]interface{}{"flap_tolerant": nil},
|
||||
}
|
||||
if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
if got := NodeFlapTolerant("node-a"); got != 0 {
|
||||
t.Errorf("NodeFlapTolerant after removal = %v, want 0", got)
|
||||
}
|
||||
entry := nodeEntry(t, "node-a")
|
||||
if _, present := entry["flap_tolerant"]; present {
|
||||
t.Error("flap_tolerant is still in the file after being cleared")
|
||||
}
|
||||
if entry["enableStatusNotify"] != true {
|
||||
t.Errorf("enableStatusNotify = %v, want it preserved as true", entry["enableStatusNotify"])
|
||||
}
|
||||
}
|
||||
+32
-20
@@ -15,12 +15,13 @@ type SystemConfig struct {
|
||||
|
||||
// DebugConfig holds debug-level configuration.
|
||||
type DebugConfig struct {
|
||||
ShowNapcatMsg bool `json:"showNapcatMsg"`
|
||||
ShowNapcatAction bool `json:"showNapcatAction"`
|
||||
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
||||
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
||||
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
||||
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"`
|
||||
ShowOneBotMsg bool `json:"showOneBotMsg"`
|
||||
ShowOneBotAction bool `json:"showOneBotAction"`
|
||||
ShowTelegramMsg bool `json:"showTelegramMsg"`
|
||||
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
|
||||
ShowKomariTaskEcho bool `json:"showKomariTaskEcho"`
|
||||
OneBotIgnoreSelfMsg bool `json:"oneBotIgnoreSelfMsg"`
|
||||
ShowFlapTolerantEcho bool `json:"showFlapTolerantEcho"`
|
||||
}
|
||||
|
||||
// KomariAccount holds Komari login credentials.
|
||||
@@ -35,14 +36,14 @@ type KomariConfig struct {
|
||||
Account KomariAccount `json:"account"`
|
||||
}
|
||||
|
||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
||||
type QQConfig struct {
|
||||
// QQConfig holds QQ (OneBot) Bot controller configuration.
|
||||
type OneBotConfig struct {
|
||||
Markdown bool `json:"markdown"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||
NapcatAddr string `json:"napcatAddr"`
|
||||
NapcatPort string `json:"napcatPort"`
|
||||
NapcatToken string `json:"napcatToken"`
|
||||
OneBotAddr string `json:"oneBotAddr"`
|
||||
OneBotPort string `json:"oneBotPort"`
|
||||
OneBotToken string `json:"oneBotToken"`
|
||||
BotQQID int64 `json:"botQQID"`
|
||||
ListenMethod string `json:"listenMethod"`
|
||||
}
|
||||
@@ -96,11 +97,11 @@ type WebhookConfig struct {
|
||||
|
||||
// ControllerMethodConfig holds all controller method configurations.
|
||||
type ControllerMethodConfig struct {
|
||||
QQ QQConfig `json:"qq(napcat)"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Ntfy NtfyConfig `json:"ntfy"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
QQ_OneBot OneBotConfig `json:"qq_onebot"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Ntfy NtfyConfig `json:"ntfy"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
}
|
||||
|
||||
// WebhookEndpointConfig holds a single incoming webhook endpoint. Endpoints are
|
||||
@@ -119,7 +120,7 @@ type WebhookEndpointConfig struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
// NotifyPipes lists the notification channels the alert is delivered to, by
|
||||
// controller name (e.g. "qq(napcat)", "telegram", "email", "ntfy",
|
||||
// controller name (e.g. "qq(onebot)", "telegram", "email", "ntfy",
|
||||
// "webhook").
|
||||
NotifyPipes []string `json:"notifyPipes"`
|
||||
}
|
||||
@@ -216,7 +217,7 @@ func (m BotUserMembers) IDs() []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// BotUser_QQConfig holds the admins and trusted groups for the QQ (Napcat) bot.
|
||||
// BotUser_QQConfig holds the admins and trusted groups for the QQ (OneBot) bot.
|
||||
type BotUser_QQConfig struct {
|
||||
Admins BotUserMembers `json:"admins"`
|
||||
TrustedGroups BotUserMembers `json:"trustedGroups"`
|
||||
@@ -232,7 +233,7 @@ type BotUser_TelegramConfig struct {
|
||||
// and trusted groups (chat targets) for each bot channel separately from the
|
||||
// main config.json.
|
||||
type BotUserConfig struct {
|
||||
QQ BotUser_QQConfig `json:"qq(napcat)"`
|
||||
QQ BotUser_QQConfig `json:"qq_onebot"`
|
||||
Telegram BotUser_TelegramConfig `json:"telegram"`
|
||||
}
|
||||
|
||||
@@ -257,13 +258,24 @@ type BotNodeOptions struct {
|
||||
// bool) lets an absent field be told apart from an explicit false, and
|
||||
// omitempty keeps untouched nodes stored as {}.
|
||||
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
||||
|
||||
// FlapTolerant is the number of seconds a node's Offline notification is
|
||||
// held in the hope the node returns. A node that comes back online within
|
||||
// this window produces no notification at all; one still offline when the
|
||||
// window elapses reports Offline as usual. Zero (or an absent field)
|
||||
// disables inhibition, which keeps the notify-immediately behavior. It is
|
||||
// a float64 rather than an int so a hand-written fractional value such as
|
||||
// 2.5 parses instead of failing the whole config load at startup, and it
|
||||
// is not a pointer because 0 already means "off", unlike
|
||||
// EnableStatusNotify whose default is true.
|
||||
FlapTolerant float64 `json:"flap_tolerant,omitempty"`
|
||||
}
|
||||
|
||||
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||
// options.
|
||||
type BotNodeMembers map[string]BotNodeOptions
|
||||
|
||||
// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig.
|
||||
// NodeConfig mirrors bot_node_config.json, populated by LoadNodeConfig.
|
||||
// The map is rebuilt rather than mutated on every load, so the pointer can be
|
||||
// swapped atomically; read it through BotNodes.
|
||||
var botNodeConfig atomic.Pointer[BotNodeMembers]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot.
|
||||
## Features
|
||||
|
||||
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
|
||||
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes.
|
||||
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load. The pencil on a card opens a per-node options dialog holding the *status notify* switch and the *flap inhibition* window, persisted to `bot_node_config.json`. Searchable, auto-refreshes.
|
||||
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
|
||||
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
|
||||
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
|
||||
|
||||
Generated
-3
@@ -1473,7 +1473,6 @@
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1596,7 +1595,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -1671,7 +1669,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
|
||||
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.42",
|
||||
"@vue/compiler-sfc": "3.5.42",
|
||||
|
||||
+156
-20
@@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
function percent(m) {
|
||||
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
||||
@@ -13,9 +14,20 @@ const query = ref('');
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const entries = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
let timer = null;
|
||||
|
||||
// The per-node options dialog. Its form is a copy of the node's settings taken
|
||||
// when the dialog opens, so the 15s card refresh behind it cannot disturb an
|
||||
// open editor, and closing without saving leaves the node untouched.
|
||||
const editing = reactive({
|
||||
open: false,
|
||||
uuid: '',
|
||||
name: '',
|
||||
notify: true,
|
||||
flap: '',
|
||||
saving: false
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return entries.value;
|
||||
@@ -57,7 +69,8 @@ async function load() {
|
||||
online: !!s.online,
|
||||
report: s.report || null,
|
||||
info: infoEntry.info || null,
|
||||
notify: conf.enableStatusNotify !== false
|
||||
notify: conf.enableStatusNotify !== false,
|
||||
flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
|
||||
@@ -71,21 +84,42 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(entry) {
|
||||
const next = !entry.notify;
|
||||
const prev = entry.notify;
|
||||
entry.notify = next;
|
||||
saving.add(entry.uuid);
|
||||
function openOptions(entry) {
|
||||
editing.uuid = entry.uuid;
|
||||
editing.name = entry.name;
|
||||
editing.notify = entry.notify;
|
||||
editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : '';
|
||||
editing.open = true;
|
||||
}
|
||||
|
||||
// saveOptions writes both options in one patch. A flap window that is not a
|
||||
// positive number disables inhibition, which is sent as a JSON null so the key
|
||||
// is removed rather than stored as an explicit 0 — the file then keeps matching
|
||||
// its documented "absent means off" default.
|
||||
async function saveOptions() {
|
||||
const typed = Number(editing.flap);
|
||||
const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0;
|
||||
const uuid = editing.uuid;
|
||||
|
||||
editing.saving = true;
|
||||
try {
|
||||
const patch = {};
|
||||
patch[entry.uuid] = { enableStatusNotify: next };
|
||||
patch[uuid] = {
|
||||
enableStatusNotify: editing.notify,
|
||||
flap_tolerant: seconds > 0 ? seconds : null
|
||||
};
|
||||
await settingsApi.set('bot_node_config', patch);
|
||||
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
|
||||
const entry = entries.value.find((e) => e.uuid === uuid);
|
||||
if (entry) {
|
||||
entry.notify = editing.notify;
|
||||
entry.flapTolerant = seconds;
|
||||
}
|
||||
toast.success(`${editing.name}: options saved`);
|
||||
editing.open = false;
|
||||
} catch (e) {
|
||||
entry.notify = prev;
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
saving.delete(entry.uuid);
|
||||
editing.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +145,6 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>Nodes</h1>
|
||||
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
||||
@@ -207,17 +240,60 @@ onBeforeUnmount(() => {
|
||||
<span>·</span>
|
||||
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
||||
</div>
|
||||
<div class="notify">
|
||||
<Toggle
|
||||
:model-value="e.notify"
|
||||
label="Status notify"
|
||||
:disabled="saving.has(e.uuid)"
|
||||
@update:model-value="toggleNotify(e)"
|
||||
/>
|
||||
<div class="opts">
|
||||
<span v-if="!e.notify" class="chip" title="Status-change notifications are off for this node">
|
||||
<i class="fas fa-bell-slash" /> Notify off
|
||||
</span>
|
||||
<span v-if="e.flapTolerant" class="chip" title="Flap inhibition window">
|
||||
<i class="fas fa-shield-halved" /> Flap {{ e.flapTolerant }}s
|
||||
</span>
|
||||
<button class="icon-btn" title="Node options" @click="openOptions(e)">
|
||||
<i class="fas fa-pen" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<Modal :open="editing.open" title="Node options" @close="editing.open = false">
|
||||
<div class="node-head">
|
||||
<p class="node-name">{{ editing.name }}</p>
|
||||
<p class="node-uuid mono">{{ editing.uuid }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Status notify</label>
|
||||
<Toggle v-model="editing.notify" label="Report online / offline changes" />
|
||||
<p class="help">With this off, the node's status changes are not delivered to any pipes.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Flap inhibition</label>
|
||||
<div class="flap-row">
|
||||
<input
|
||||
v-model="editing.flap"
|
||||
class="input flap-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="86400"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
@keydown.enter="saveOptions"
|
||||
/>
|
||||
<span class="flap-unit">seconds</span>
|
||||
</div>
|
||||
<p class="help">How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.</p>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="editing.open = false">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="editing.saving" @click="saveOptions">
|
||||
<span v-if="editing.saving" class="spinner" style="width:12px;height:12px" />
|
||||
<i v-else class="fas fa-check" /> Save
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -298,7 +374,67 @@ onBeforeUnmount(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notify { display: flex; align-items: center; }
|
||||
.opts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* Stay right-aligned even when the spec block is absent. */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.opts .chip { white-space: nowrap; }
|
||||
|
||||
/* --- Node options dialog ------------------------------------------------ */
|
||||
|
||||
.node-head {
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.node-uuid {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The modal body has no grid to space stacked fields. */
|
||||
.field + .field { margin-top: 20px; }
|
||||
|
||||
.flap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.flap-input {
|
||||
width: 110px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Match the plain hand-rolled field look: the rest of the console renders
|
||||
numbers as ordinary inputs, so the spinner controls would stand out. */
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.flap-input::-webkit-outer-spin-button,
|
||||
.flap-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flap-unit {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.refresh-hint {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -23,15 +23,16 @@ const sections = [
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug logging',
|
||||
hint: 'Per-module verbosity for the bot pipes.',
|
||||
hint: '',
|
||||
root: ['debug'],
|
||||
fields: [
|
||||
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
|
||||
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
|
||||
{ key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' },
|
||||
{ key: 'showOneBotAction', type: 'bool', label: 'OneBot actions' },
|
||||
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
||||
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
||||
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
||||
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
|
||||
{ key: 'showFlapTolerantEcho', type: 'bool', label: 'Flap inhibition echo' },
|
||||
{ key: 'onebotIgnoreSelfMsg', type: 'bool', label: 'Ignore OneBot self messages' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -60,16 +61,16 @@ const sections = [
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
title: 'QQ controller (NapCat)',
|
||||
root: ['controllerMethod', 'qq(napcat)'],
|
||||
id: 'qq_onebot',
|
||||
title: 'QQ controller (OneBot)',
|
||||
root: ['controllerMethod', 'qq_onebot'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
||||
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
|
||||
{ key: 'oneBotAddr', type: 'text', label: 'OneBot address' },
|
||||
{ key: 'oneBotPort', type: 'text', label: 'OneBot port' },
|
||||
{ key: 'oneBotToken', type: 'password', label: 'OneBot token' },
|
||||
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
@@ -80,7 +81,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'telegram'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Sends messages with parse_mode=Markdown; turn off to have text delivered verbatim.' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'botToken', type: 'password', label: 'Bot token' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
@@ -92,7 +93,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'email'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
||||
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
||||
@@ -109,7 +110,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'ntfy'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Send the formatted variant of the templates (code blocks, inline code).' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'server', type: 'text', label: 'Server' },
|
||||
{ key: 'topic', type: 'text', label: 'Topic' },
|
||||
@@ -124,7 +125,7 @@ const sections = [
|
||||
root: ['controllerMethod', 'webhook'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown', help: 'Format the alert body with code blocks and inline code in the posted payload.' },
|
||||
{ key: 'markdown', type: 'bool', label: 'Markdown' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'url', type: 'text', label: 'URL' },
|
||||
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||
@@ -170,7 +171,6 @@ onMounted(load);
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>System settings</h1>
|
||||
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
|
||||
{ key: 'qq_onebot', label: 'QQ (OneBot)', hint: 'Members are identified by QQ number / group number' },
|
||||
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
|
||||
];
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Modal from '../components/Modal.vue';
|
||||
// controller names in config.json (controllerMethod.*), which is exactly what
|
||||
// the backend matches notifyPipes against.
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ' },
|
||||
{ key: 'qq_onebot', label: 'QQ_OneBot' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'ntfy', label: 'ntfy' },
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
|
||||
|
||||
var SoftwareInfo = SoftwareInfoStr{
|
||||
Name: "Nukumizu",
|
||||
Version: "0.2.1",
|
||||
Version: "0.2.2",
|
||||
Developer: "Madobi Nanami",
|
||||
BuildVer: 6,
|
||||
BuildVer: 7,
|
||||
CommitHash: "unknown",
|
||||
Description: "Remote server monitoring and command execution subsystem for Komari",
|
||||
BuildType: "pre-release",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
@@ -27,7 +28,7 @@ type Command struct {
|
||||
|
||||
// Message represents a message to be sent by a controller.
|
||||
type Message struct {
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
||||
Source string // The source pipe (e.g., "telegram", "qq", "onebot")
|
||||
Content string // The message content
|
||||
ChatID int64 // Chat/group ID where the message should be sent
|
||||
Type string // Message type (see MessageType*), used for per-member opt-outs
|
||||
@@ -100,13 +101,22 @@ type Controller interface {
|
||||
|
||||
// BotController is implemented by controllers that act as chat bots and can
|
||||
// deliver arbitrary messages, such as the bot initialization message on startup.
|
||||
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only
|
||||
// Only bot-type pipes (QQ_OneBot, Telegram) implement it; notification-only
|
||||
// pipes (email, ntfy, webhook) do not.
|
||||
type BotController interface {
|
||||
Controller
|
||||
SendMessage(message Message) error
|
||||
}
|
||||
|
||||
// flapPending is an Offline notification being held back for a node's
|
||||
// flap_tolerant window, in the hope that the node returns before the deadline.
|
||||
// The held change is kept by value so a later mutation of the caller's copy
|
||||
// cannot reach it.
|
||||
type flapPending struct {
|
||||
timer *time.Timer
|
||||
change node.StatusChange
|
||||
}
|
||||
|
||||
// Manager manages all controller instances and routes events.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
@@ -117,6 +127,19 @@ type Manager struct {
|
||||
// from one that does not. It is read on the settings-update goroutine and
|
||||
// written when the set is replaced.
|
||||
builtFrom atomic.Pointer[config.ControllerMethodConfig]
|
||||
|
||||
// flapMu guards flapPending, the Offline notifications currently held for
|
||||
// flap inhibition, keyed by node UUID. It is a separate lock from mu and is
|
||||
// never held while mu is taken: a held notification is dispatched by a timer
|
||||
// goroutine, which must not block behind a routing swap, and dispatching
|
||||
// (network I/O per controller) must not happen while holding the flap lock.
|
||||
flapMu sync.Mutex
|
||||
flapPending map[string]*flapPending
|
||||
|
||||
// flapToleranceFor overrides the per-node flap window. It is nil in
|
||||
// production, where the value comes from bot_node_config.json through
|
||||
// config.NodeFlapTolerant; tests set it to keep their timings hermetic.
|
||||
flapToleranceFor func(uuid string) time.Duration
|
||||
}
|
||||
|
||||
var globalManager *Manager
|
||||
@@ -125,6 +148,7 @@ var globalManager *Manager
|
||||
func InitManager() {
|
||||
globalManager = &Manager{
|
||||
controllers: make(map[string]Controller),
|
||||
flapPending: make(map[string]*flapPending),
|
||||
}
|
||||
postLog.Info("Controller manager initialized")
|
||||
}
|
||||
@@ -140,7 +164,7 @@ func GetManager() *Manager {
|
||||
//
|
||||
// Controllers are rebuilt wholesale rather than reconfigured in place: each one
|
||||
// reads its settings into fields at construction, and two of them own
|
||||
// connections that cannot be re-pointed (the 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
|
||||
// controller). Replacing the set keeps every channel on the same footing.
|
||||
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
|
||||
// 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
|
||||
// routable, and each one can send before Start returns.
|
||||
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
|
||||
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
|
||||
// bot controllers (QQ_OneBot and Telegram). Notification-only pipes that do
|
||||
// not implement BotController are skipped. The message is typed
|
||||
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
||||
// EventBotStarted opt-out. It is rendered once per controller because the
|
||||
@@ -268,12 +292,37 @@ func (m *Manager) ShowBotServerList() {
|
||||
// controllers. It honors the per-node allow-list in bot_node_config.json: a
|
||||
// node whose enableStatusNotify is not true is skipped entirely, so no
|
||||
// controller (chat bots or notification pipes) broadcasts its change.
|
||||
//
|
||||
// It also applies flap inhibition, the counterpart of the allow-list and read
|
||||
// from the same file: an Offline notification for a node with a positive
|
||||
// flap_tolerant is held back for that many seconds instead of being broadcast
|
||||
// (see holdOffline). Because both gates live here, every controller is subject
|
||||
// to them, chat bots and notification pipes alike.
|
||||
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
||||
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
||||
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
||||
return
|
||||
}
|
||||
|
||||
switch change.Event {
|
||||
case "Offline":
|
||||
if tolerance := m.flapTolerance(change.UUID); tolerance > 0 {
|
||||
m.holdOffline(change, tolerance)
|
||||
return
|
||||
}
|
||||
case "Online":
|
||||
// A node that returns while its Offline is still held flaps: both
|
||||
// halves of the pair are suppressed, and nothing is sent at all.
|
||||
if m.releaseHeldOffline(change.UUID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.dispatchStatusChange(change)
|
||||
}
|
||||
|
||||
// dispatchStatusChange hands a change to every enabled controller.
|
||||
func (m *Manager) dispatchStatusChange(change node.StatusChange) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -340,8 +499,19 @@ func (m *Manager) IsMarkdown(pipeName string) bool {
|
||||
return ctrl.IsMarkdown()
|
||||
}
|
||||
|
||||
// StopAll stops all registered controllers.
|
||||
// StopAll stops all registered controllers. Held Offline notifications are
|
||||
// dropped first: their timers would otherwise fire against a shutting-down
|
||||
// controller set. A timer that has already claimed its entry can still be
|
||||
// dispatching concurrently, which is harmless — it reads the same registry
|
||||
// under the same lock.
|
||||
func (m *Manager) StopAll() {
|
||||
m.flapMu.Lock()
|
||||
for uuid, held := range m.flapPending {
|
||||
held.timer.Stop()
|
||||
delete(m.flapPending, uuid)
|
||||
}
|
||||
m.flapMu.Unlock()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, ctrl := range m.controllers {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -79,6 +81,228 @@ func (m *Manager) routedTo(name string) Controller {
|
||||
return m.controllers[name]
|
||||
}
|
||||
|
||||
// heldOffline returns the Offline notification the manager is currently holding
|
||||
// for the given node, if any. It lets a test drive the hold/fire/release race
|
||||
// deterministically instead of racing a real timer.
|
||||
func (m *Manager) heldOffline(uuid string) *flapPending {
|
||||
m.flapMu.Lock()
|
||||
defer m.flapMu.Unlock()
|
||||
return m.flapPending[uuid]
|
||||
}
|
||||
|
||||
// recordingController keeps every status change it is handed, so a test can
|
||||
// assert on what actually reached the controllers. The embedded fakeController
|
||||
// satisfies the rest of the Controller interface.
|
||||
type recordingController struct {
|
||||
*fakeController
|
||||
changes chan node.StatusChange
|
||||
}
|
||||
|
||||
func newRecording(name string) *recordingController {
|
||||
return &recordingController{
|
||||
fakeController: newFake(name),
|
||||
changes: make(chan node.StatusChange, 16),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recordingController) SendStatusChange(change node.StatusChange) error {
|
||||
r.changes <- change
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitChange blocks until a status change reaches the controller, failing the
|
||||
// test rather than hanging if none does.
|
||||
func awaitChange(t *testing.T, rec *recordingController, what string) node.StatusChange {
|
||||
t.Helper()
|
||||
select {
|
||||
case change := <-rec.changes:
|
||||
return change
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for the %s notification", what)
|
||||
return node.StatusChange{}
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoChange asserts that no status change reaches the controller for the
|
||||
// given window. Only a negative assertion needs a real wait; the manager has no
|
||||
// observable "decided to stay silent" event.
|
||||
func expectNoChange(t *testing.T, rec *recordingController, window time.Duration, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case change := <-rec.changes:
|
||||
t.Fatalf("unexpected %s notification: %+v", what, change)
|
||||
case <-time.After(window):
|
||||
}
|
||||
}
|
||||
|
||||
func offlineChange(uuid string) node.StatusChange {
|
||||
return node.StatusChange{Event: "Offline", UUID: uuid, Name: uuid, OldOnline: true, NewOnline: false}
|
||||
}
|
||||
|
||||
func onlineChange(uuid string) node.StatusChange {
|
||||
return node.StatusChange{Event: "Online", UUID: uuid, Name: uuid, OldOnline: false, NewOnline: true}
|
||||
}
|
||||
|
||||
// flapTestManager returns a manager with a recording controller installed and
|
||||
// the flap window stubbed to tolerance, so the tests never touch the global
|
||||
// bot_node_config.json or wait on real seconds. ReplaceAll makes the controller
|
||||
// routable before it starts it, so no start handshake is needed here.
|
||||
func flapTestManager(tolerance time.Duration) (*Manager, *recordingController) {
|
||||
m := newTestManager()
|
||||
m.flapToleranceFor = func(string) time.Duration { return tolerance }
|
||||
rec := newRecording("recorder")
|
||||
m.ReplaceAll([]Controller{rec}, config.ControllerMethodConfig{})
|
||||
return m, rec
|
||||
}
|
||||
|
||||
// TestFlapInhibitionSuppressesBothHalves is the core guarantee: a node that
|
||||
// returns inside its flap window produces no notification at all, so a flapping
|
||||
// node does not flood the controllers.
|
||||
func TestFlapInhibitionSuppressesBothHalves(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("flappy"))
|
||||
m.NotifyStatusChange(onlineChange("flappy"))
|
||||
|
||||
expectNoChange(t, rec, 300*time.Millisecond, "suppressed offline/online pair")
|
||||
|
||||
if held := m.heldOffline("flappy"); held != nil {
|
||||
t.Error("the held offline notification was not released when the node returned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionReportsLateOffline: a node still down when the window
|
||||
// elapses reports Offline as usual, however late the notification is.
|
||||
func TestFlapInhibitionReportsLateOffline(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("down"))
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" || change.UUID != "down" {
|
||||
t.Errorf("got %+v, want the Offline change for node down", change)
|
||||
}
|
||||
if held := m.heldOffline("down"); held != nil {
|
||||
t.Error("the held notification was still pending after it was dispatched")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionReportsRecoveryAfterWindow: once the Offline has gone out,
|
||||
// the node's eventual return is a genuine recovery and must be reported. This
|
||||
// is the honest half of the "both or neither, never Offline alone" rule.
|
||||
func TestFlapInhibitionReportsRecoveryAfterWindow(t *testing.T) {
|
||||
m, rec := flapTestManager(50 * time.Millisecond)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("down"))
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Fatalf("got %+v, want the Offline change", change)
|
||||
}
|
||||
|
||||
m.NotifyStatusChange(onlineChange("down"))
|
||||
if change := awaitChange(t, rec, "online"); change.Event != "Online" {
|
||||
t.Errorf("got %+v, want the Online change", change)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionDisabledWithoutTolerance guards the previous behavior: with
|
||||
// no window (the default for every node), an Offline still goes out at once.
|
||||
func TestFlapInhibitionDisabledWithoutTolerance(t *testing.T) {
|
||||
m, rec := flapTestManager(0)
|
||||
|
||||
m.NotifyStatusChange(offlineChange("plain"))
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Errorf("got %+v, want the Offline change", change)
|
||||
}
|
||||
if held := m.heldOffline("plain"); held != nil {
|
||||
t.Error("a node without a flap window must not have a held notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionHonorsEnableStatusNotify pins the gate ordering: a node
|
||||
// whose notifications are switched off is skipped before any hold is taken, so
|
||||
// it cannot report a late Offline either.
|
||||
func TestFlapInhibitionHonorsEnableStatusNotify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bot_node_config.json")
|
||||
content := `{"muted": {"enableStatusNotify": false, "flap_tolerant": 600}}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write node config: %v", err)
|
||||
}
|
||||
if err := config.LoadBotNodeConfig(path); err != nil {
|
||||
t.Fatalf("LoadBotNodeConfig: %v", err)
|
||||
}
|
||||
// Leave nothing behind for the other tests in this package, which reach the
|
||||
// same package-global node config.
|
||||
t.Cleanup(func() {
|
||||
if err := config.LoadBotNodeConfig(filepath.Join(dir, "empty.json")); err != nil {
|
||||
t.Errorf("reset node config: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// The stub reports a window, so only the allow-list can be responsible for
|
||||
// the silence.
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("muted"))
|
||||
|
||||
expectNoChange(t, rec, 100*time.Millisecond, "offline for a muted node")
|
||||
if held := m.heldOffline("muted"); held != nil {
|
||||
t.Error("a muted node must not have its Offline held")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlapInhibitionClaimOrder pins both sides of the hold/fire/release race
|
||||
// deterministically, with a window long enough that the real timer never fires:
|
||||
// whichever claims the held notification first wins, and the loser sees it gone.
|
||||
func TestFlapInhibitionClaimOrder(t *testing.T) {
|
||||
t.Run("release first suppresses the pair", func(t *testing.T) {
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
held := m.heldOffline("node")
|
||||
if held == nil {
|
||||
t.Fatal("the offline notification was not held")
|
||||
}
|
||||
if !m.releaseHeldOffline("node") {
|
||||
t.Fatal("releaseHeldOffline reported no pending notification")
|
||||
}
|
||||
// The timer goroutine loses the claim and must not dispatch.
|
||||
m.fireHeldOffline("node", held)
|
||||
|
||||
expectNoChange(t, rec, 100*time.Millisecond, "notification after a release")
|
||||
})
|
||||
|
||||
t.Run("fire first reports the offline alone", func(t *testing.T) {
|
||||
m, rec := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
held := m.heldOffline("node")
|
||||
if held == nil {
|
||||
t.Fatal("the offline notification was not held")
|
||||
}
|
||||
m.fireHeldOffline("node", held)
|
||||
|
||||
if change := awaitChange(t, rec, "offline"); change.Event != "Offline" {
|
||||
t.Errorf("got %+v, want the Offline change", change)
|
||||
}
|
||||
if m.releaseHeldOffline("node") {
|
||||
t.Error("a fired notification must not be claimable again")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopAllDropsHeldNotifications: a hold must not outlive the controllers it
|
||||
// was meant for.
|
||||
func TestStopAllDropsHeldNotifications(t *testing.T) {
|
||||
m, _ := flapTestManager(time.Hour)
|
||||
m.NotifyStatusChange(offlineChange("node"))
|
||||
|
||||
m.StopAll()
|
||||
|
||||
if held := m.heldOffline("node"); held != nil {
|
||||
t.Error("StopAll left a held offline notification armed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAllInstallsAndStarts(t *testing.T) {
|
||||
m := newTestManager()
|
||||
alpha, beta := newFake("alpha"), newFake("beta")
|
||||
@@ -97,7 +321,7 @@ func TestReplaceAllInstallsAndStarts(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestReplaceAllStopsTheOutgoingSet(t *testing.T) {
|
||||
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, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params["group_id"].(float64) != 12345 || params["message"].(string) != "hello" {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
var echo string
|
||||
if err := json.Unmarshal(req.Echo, &echo); err != nil {
|
||||
t.Fatalf("echo is not a JSON string: %s", req.Echo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodylessActionSendsEmptyParams checks actions without arguments still send
|
||||
// a params object (never null), which some adapters reject.
|
||||
func TestBodylessActionSendsEmptyParams(t *testing.T) {
|
||||
f := newFakeOneBot(t)
|
||||
|
||||
c := f.client(t)
|
||||
go c.Listen(func([]byte) {})
|
||||
defer c.Stop()
|
||||
|
||||
if _, err := c.GetGroupList(); err != nil {
|
||||
t.Fatalf("GetGroupList: %v", err)
|
||||
}
|
||||
|
||||
req := <-f.apiRequests
|
||||
if req.Action != "get_group_list" {
|
||||
t.Fatalf("action = %q, want get_group_list", req.Action)
|
||||
}
|
||||
if string(req.Params) != "{}" {
|
||||
t.Fatalf("params = %s, want {}", req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMsgFromEventCallbackDoesNotDeadlock is the regression test for the
|
||||
// reason the client uses two sockets: the event handler replies with SendMsg from
|
||||
// inside the event read loop, so a response read on that same connection would
|
||||
// never arrive.
|
||||
func TestSendMsgFromEventCallbackDoesNotDeadlock(t *testing.T) {
|
||||
f := newFakeOneBot(t)
|
||||
|
||||
c := f.client(t)
|
||||
|
||||
type result struct {
|
||||
resp *APIResponse
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go c.Listen(func(raw []byte) {
|
||||
resp, err := c.SendMsg("group", 1, "pong", false, 0)
|
||||
done <- result{resp, err}
|
||||
})
|
||||
defer c.Stop()
|
||||
|
||||
evConn := f.eventConn(t)
|
||||
event := []byte(`{"post_type":"message","message_type":"group","group_id":1,"user_id":2,"raw_message":"/ping","self_id":9}`)
|
||||
if err := evConn.WriteMessage(websocket.TextMessage, event); err != nil {
|
||||
t.Fatalf("write event: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
t.Fatalf("SendMsg from event callback: %v", r.err)
|
||||
}
|
||||
if r.resp.Status != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", r.resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("deadlock: SendMsg did not return from within the event callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoKey covers the response-matcher: an absent or null echo is not a
|
||||
// response, and both string and non-string echoes normalize to a key.
|
||||
func TestEchoKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
key string
|
||||
ok bool
|
||||
}{
|
||||
{`"42"`, "42", true},
|
||||
{`42`, "42", true},
|
||||
{`null`, "", false},
|
||||
{``, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
key, ok := echoKey(json.RawMessage(tc.in))
|
||||
if key != tc.key || ok != tc.ok {
|
||||
t.Errorf("echoKey(%q) = (%q, %v), want (%q, %v)", tc.in, key, ok, tc.key, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-46
@@ -1,4 +1,4 @@
|
||||
package qq_napcat
|
||||
package qq_onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket.
|
||||
// oneBotEvent mirrors a OneBot 11 event pushed over the OneBot WebSocket.
|
||||
// Only "message" events are handled; notice/request/meta_event are ignored.
|
||||
type oneBotEvent struct {
|
||||
PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event"
|
||||
@@ -29,45 +29,46 @@ type oneBotEvent struct {
|
||||
SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc.
|
||||
}
|
||||
|
||||
// QQController handles QQ Bot interactions by connecting directly to NapCat.
|
||||
// QQController handles QQ Bot interactions by connecting directly to OneBot.
|
||||
type QQController struct {
|
||||
cfg config.QQConfig
|
||||
napcatClient *Client
|
||||
cfg config.OneBotConfig
|
||||
onebotClient *Client
|
||||
}
|
||||
|
||||
// NewQQController creates a new QQ (Napcat) controller.
|
||||
func NewQQController(cfg config.QQConfig) *QQController {
|
||||
// NewQQController creates a new QQ (OneBot) controller.
|
||||
func NewQQController(cfg config.OneBotConfig) *QQController {
|
||||
q := &QQController{cfg: cfg}
|
||||
if cfg.Enabled {
|
||||
q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy)
|
||||
q.onebotClient = NewClient(cfg.OneBotAddr, cfg.OneBotPort, cfg.OneBotToken, cfg.NetworkUseProxy)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// Name returns the controller name.
|
||||
func (q *QQController) Name() string {
|
||||
return "qq(napcat)"
|
||||
return "qq_onebot"
|
||||
}
|
||||
|
||||
// Start initializes the QQ controller and starts the NapCat WebSocket listener.
|
||||
// Start initializes the QQ controller and starts the OneBot WebSocket listeners
|
||||
// (the event socket and the API socket the client manages internally).
|
||||
func (q *QQController) Start() error {
|
||||
if !q.cfg.Enabled {
|
||||
// postLog.Info("QQ (Napcat) controller is disabled")
|
||||
return nil
|
||||
}
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] QQ (OneBot) controller enabled but OneBot client is nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...")
|
||||
postLog.Info("[qq_onebot] QQ (OneBot) controller started, connecting to OneBot WebSocket...")
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r))
|
||||
postLog.Error("[qq_onebot] OneBot WS listener panic recovered: " + fmt.Sprintf("%v", r))
|
||||
}
|
||||
}()
|
||||
q.napcatClient.Listen(q.handleNapcatEvent)
|
||||
q.onebotClient.Listen(q.handleOneBotEvent)
|
||||
}()
|
||||
|
||||
return nil
|
||||
@@ -75,10 +76,10 @@ func (q *QQController) Start() error {
|
||||
|
||||
// Stop shuts down the QQ controller and its WebSocket listener.
|
||||
func (q *QQController) Stop() {
|
||||
if q.napcatClient != nil {
|
||||
q.napcatClient.Stop()
|
||||
if q.onebotClient != nil {
|
||||
q.onebotClient.Stop()
|
||||
}
|
||||
postLog.Info("QQ (Napcat) controller stopped")
|
||||
postLog.Info("[qq_onebot] QQ (OneBot) controller stopped")
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the controller is enabled.
|
||||
@@ -92,11 +93,11 @@ func (q *QQController) IsMarkdown() bool {
|
||||
return q.cfg.Markdown
|
||||
}
|
||||
|
||||
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
|
||||
func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
// handleOneBotEvent processes a raw OneBot event received from the OneBot WebSocket.
|
||||
func (q *QQController) handleOneBotEvent(raw []byte) {
|
||||
var ev oneBotEvent
|
||||
if err := json.Unmarshal(raw, &ev); err != nil {
|
||||
postLog.Debug("Failed to parse NapCat WS event: " + err.Error())
|
||||
postLog.Debug("[qq_onebot] Failed to parse OneBot WS event: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,16 +109,16 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
return
|
||||
}
|
||||
debugMode := cfg.System.DebugMode
|
||||
showAction := debugMode && cfg.Debug.ShowNapcatAction
|
||||
showAction := debugMode && cfg.Debug.ShowOneBotAction
|
||||
|
||||
if debugMode && cfg.Debug.ShowNapcatMsg {
|
||||
postLog.Debug("Napcat WS event received: " + string(raw))
|
||||
if debugMode && cfg.Debug.ShowOneBotMsg {
|
||||
postLog.Debug("[qq_onebot] OneBot WS event received: " + string(raw))
|
||||
}
|
||||
|
||||
// Only handle message events; ignore notice/request/meta_event.
|
||||
if ev.PostType != "message" {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS event: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] Ignoring OneBot WS event: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -126,9 +127,9 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
if q.isSelfMessage(ev) && !debugMode {
|
||||
return
|
||||
}
|
||||
if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg {
|
||||
if q.isSelfMessage(ev) && debugMode && cfg.Debug.OneBotIgnoreSelfMsg {
|
||||
if showAction {
|
||||
postLog.Debug("Ignoring Napcat WS self message: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] Ignoring OneBot WS self message: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -149,19 +150,19 @@ func (q *QQController) handleNapcatEvent(raw []byte) {
|
||||
response := q.processCommand(cmd)
|
||||
if response == "" {
|
||||
if showAction {
|
||||
postLog.Debug("Napcat WS command discarded: " + string(raw))
|
||||
postLog.Debug("[qq_onebot] OneBot WS command discarded: " + string(raw))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reply in the same chat.
|
||||
if ev.MessageType == "private" {
|
||||
if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
||||
postLog.Warning("Failed to send NapCat private reply: " + err.Error())
|
||||
if _, err := q.onebotClient.SendMsg("private", ev.UserID, response, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send OneBot private reply: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
||||
postLog.Warning("Failed to send NapCat group reply: " + err.Error())
|
||||
if _, err := q.onebotClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send OneBot group reply: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +180,8 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
if q.cfg.ListenMethod == "at" {
|
||||
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
|
||||
if !strings.Contains(text, atMention) {
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction {
|
||||
postLog.Debug("Napcat WS message ignored (no @mention): " + text)
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowOneBotAction {
|
||||
postLog.Debug("[qq_onebot] OneBot WS message ignored (no @mention): " + text)
|
||||
}
|
||||
return "" // Not mentioned, ignore.
|
||||
}
|
||||
@@ -202,12 +203,13 @@ func (q *QQController) processCommand(cmd controller.Command) string {
|
||||
// vs private, trusted groups, admin permissions, and executes it.
|
||||
response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod)
|
||||
if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho {
|
||||
postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
||||
postLog.Debug(fmt.Sprintf("[qq_onebot] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
||||
}
|
||||
if err != nil {
|
||||
postLog.Error("Napcat command processing failed: " + err.Error())
|
||||
postLog.Error("[qq_onebot] OneBot command processing failed: " + err.Error())
|
||||
return ""
|
||||
}
|
||||
// postLog.Debug("[qq_onebot] processCommand.resp: " + response)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -341,35 +343,35 @@ func (q *QQController) SendAlert(alert controller.Alert) error {
|
||||
}
|
||||
|
||||
func (q *QQController) sendGroupMessage(groupID string, message string) {
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] Cannot send QQ group message: OneBot client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var groupIDInt int64
|
||||
if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 {
|
||||
postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID))
|
||||
postLog.Warning("[qq_onebot] Invalid QQ group ID: " + groupID)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err))
|
||||
if _, err := q.onebotClient.SendMsg("group", groupIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send QQ group message to " + groupID + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QQController) sendPrivateMessage(userID string, message string) {
|
||||
if q.napcatClient == nil {
|
||||
postLog.Warning("Cannot send QQ private message: NapCat client not initialized")
|
||||
if q.onebotClient == nil {
|
||||
postLog.Warning("[qq_onebot] Cannot send QQ private message: OneBot client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var userIDInt int64
|
||||
if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 {
|
||||
postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID))
|
||||
postLog.Warning("[qq_onebot] Invalid QQ user ID: " + userID)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err))
|
||||
if _, err := q.onebotClient.SendMsg("private", userIDInt, message, false, 0); err != nil {
|
||||
postLog.Warning("[qq_onebot] Failed to send QQ private message to " + userID + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// Trigger validates an incoming command against the pipe's authorization
|
||||
@@ -16,9 +18,11 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
||||
// Check whether the command arrived in a group or a private chat.
|
||||
if cmd.ChatType == "group" {
|
||||
// Group commands are only honored from trusted groups, otherwise discard.
|
||||
// postLog.Debug(fmt.Sprintf("trustedGroups: %v", trustedGroups))
|
||||
if !IsTrustedGroup(cmd.ChatID, trustedGroups) {
|
||||
return "", nil
|
||||
}
|
||||
postLog.Debug("isTrustedGroup")
|
||||
}
|
||||
|
||||
// Commands that require special permission must be sent by an admin.
|
||||
@@ -37,6 +41,7 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
|
||||
if response == "" && listenMethod == "at" {
|
||||
return "Unknown command: /" + cmd.Command, nil
|
||||
}
|
||||
// postLog.Debug("[controller] Trigger.resp: " + response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"nukumizu-backend/global"
|
||||
"nukumizu-backend/internal/controller"
|
||||
"nukumizu-backend/internal/controller/pipes"
|
||||
"nukumizu-backend/internal/controller/pipes/qq_napcat"
|
||||
"nukumizu-backend/internal/controller/pipes/qq_onebot"
|
||||
"nukumizu-backend/internal/controller/pipes/telegram"
|
||||
"nukumizu-backend/internal/komari"
|
||||
"nukumizu-backend/internal/node"
|
||||
@@ -72,7 +72,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 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
|
||||
// replacing the whole set rather than reconfiguring it in place.
|
||||
postLog.Info("Controller settings changed; rebuilding every channel")
|
||||
@@ -131,7 +131,7 @@ func main() {
|
||||
initControllers()
|
||||
|
||||
// Send the bot initialization message to all enabled bot controllers
|
||||
// (QQ_NapCat, Telegram), then the server list right after it once the
|
||||
// (QQ_OneBot, Telegram), then the server list right after it once the
|
||||
// initial status refresh has completed so the list reflects the real
|
||||
// online/offline state.
|
||||
if mgr := controller.GetManager(); mgr != nil {
|
||||
@@ -226,7 +226,7 @@ func startWebhookServer(cfg *config.Config) {
|
||||
// own connections that cannot be re-pointed.
|
||||
func buildControllers(cfg *config.Config) []controller.Controller {
|
||||
return []controller.Controller{
|
||||
qq_napcat.NewQQController(cfg.ControllerMethod.QQ),
|
||||
qq_onebot.NewQQController(cfg.ControllerMethod.QQ_OneBot),
|
||||
telegram.NewTelegramController(cfg.ControllerMethod.Telegram),
|
||||
pipes.NewEmailController(cfg.ControllerMethod.Email),
|
||||
pipes.NewNtfyController(cfg.ControllerMethod.Ntfy),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Runs the server from source, with the build metadata stamped in.
|
||||
#
|
||||
# The console is embedded in the binary (web/embed.go), so compiling without
|
||||
# web/dist fails. Say that plainly rather than leaving go:embed's error.
|
||||
set -euo pipefail
|
||||
|
||||
# Run from the repository root, however the script was invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -f web/dist/index.html ]; then
|
||||
echo "The web console is not built: web/dist is missing." >&2
|
||||
echo "Run build-linux-x86_64.sh once, or \"npm run build\" in frontend/." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get git commit hash (shortened to 7 characters, can also use full)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
|
||||
# Get UTC time
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Build -ldflags
|
||||
LDFLAGS="-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT"
|
||||
go run -ldflags "$LDFLAGS" .
|
||||
Reference in new issue
Block a user