diff --git a/REMAINING_PLAN.md b/REMAINING_PLAN.md index 0f9e306..48313c8 100644 --- a/REMAINING_PLAN.md +++ b/REMAINING_PLAN.md @@ -85,7 +85,7 @@ P0 是产品开始真实使用的必要闭环。P1 需在 P0 稳定后进入公 - 运行状态能区别“未启动、连接中、运行中、退避重连、停止中、异常”。 - 当前接口中返回 501 的启动/停止行为被真实运行时替代。 -**状态:未实现协议运行时。** 当前仓库存在适配器配置 API/类型;README 说明协议连接未实现,启动接口有意返回 501。 +**状态:部分完成,真实账号待验收。** 已实现 OneBot 11 正向 WebSocket 适配器、Bearer 鉴权与身份探测、状态/能力/启停/重启/发送/群列表 API、断线退避重连、启动恢复及凭据 API 脱敏。WebSocket 模拟测试覆盖鉴权、身份握手、事件接收、消息发送和断线重连。当前尚无真实 OneBot 测试账号,真实平台收发、限流响应与平台差异仍待验收;Milky、Telegram、OpenQQ 只保留类型目录,不表示已支持。 ## 5. P0:Minecraft 桥接端与服务器连接 @@ -532,6 +532,8 @@ GET /api/adapter/{id}/capabilities 验收:模拟器覆盖握手/鉴权/断线/限流/发送;单适配器故障隔离;至少经授权使用真实测试账号完成收发验证。 +实现边界:运行时当前能区分未启动、启动中、已连接、退避重连、停止中和失败配置;平台消息正文不写入运行日志。模拟器已覆盖握手、Bearer token、事件、发送和断线恢复;尚未实现平台限流语义,也未进行真实账号收发,因此 WP-03 保持部分完成。 + ### WP-04:Minecraft 桥接协议与首个服务端实现 前置决策:确定 Paper/Bukkit、Velocity、Mod 或 MCDR 中一种和支持版本。未确定时只输出协议评估,不同时实现多个桥接端。 diff --git a/dev/README.md b/dev/README.md index 074724c..0ba15e3 100644 --- a/dev/README.md +++ b/dev/README.md @@ -34,9 +34,9 @@ The API listens on `http://127.0.0.1:26990`; Vite serves the UI on its displayed ## Current implementation -The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed. +The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. The first active adapter runtime is OneBot 11 over forward WebSocket, with Bearer authentication, identity handshake, group/private message event normalization, send/group-list calls, lifecycle controls, status and capabilities APIs, and bounded reconnect backoff. Enabled OneBot 11 configurations are restored at startup. Adapter credentials are omitted from adapter API responses and blank edits preserve a saved token. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed. -Protocol connections (OneBot 11, Milky, Telegram, OpenQQ), game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging are not implemented yet. Adapter start intentionally returns HTTP 501. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; `../decompiled` is used as behavioral reference for its WebUI API and module names. +Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; `../decompiled` is used as behavioral reference for its WebUI API and module names. ## Verify diff --git a/dev/cmd/easybot/main.go b/dev/cmd/easybot/main.go index beca164..e559378 100644 --- a/dev/cmd/easybot/main.go +++ b/dev/cmd/easybot/main.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "easybot/internal/adapter/onebot11" "easybot/internal/config" "easybot/internal/runtime" "easybot/internal/store" @@ -33,6 +34,8 @@ func main() { logger.Error("listener configuration rejected", "address", address, "error", err) os.Exit(1) } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() logs := runtime.NewLogBuffer(500) logSettings, err := db.GetLogSettings(context.Background()) if err != nil { @@ -49,15 +52,19 @@ func main() { }() logs.Add("info", "runtime", "EasyBot API started") go maintainLogs(db, logger) + adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) { + logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")") + }) + if err := adapters.StartEnabled(ctx); err != nil { + logger.Error("adapter restoration failed", "error", err) + } server := &http.Server{ Addr: address, - Handler: web.New(db, logger, logs, writer), + Handler: web.NewWithAdapterManager(db, logger, logs, adapters, writer), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, } logger.Info("EasyBot web service listening", "address", address, "database", db.Dialect()) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() serveErr := make(chan error, 1) go func() { if cert != "" { @@ -70,6 +77,7 @@ func main() { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + adapters.Shutdown(shutdownCtx) if err := server.Shutdown(shutdownCtx); err != nil { logger.Error("graceful shutdown failed", "error", err) } diff --git a/dev/go.mod b/dev/go.mod index 019d640..36d25fd 100644 --- a/dev/go.mod +++ b/dev/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/go-sql-driver/mysql v1.9.3 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.6 golang.org/x/crypto v0.37.0 modernc.org/sqlite v1.39.1 diff --git a/dev/go.sum b/dev/go.sum index 3355ec2..d6ab1bf 100644 --- a/dev/go.sum +++ b/dev/go.sum @@ -11,6 +11,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/dev/internal/adapter/onebot11/manager.go b/dev/internal/adapter/onebot11/manager.go new file mode 100644 index 0000000..e8643bb --- /dev/null +++ b/dev/internal/adapter/onebot11/manager.go @@ -0,0 +1,230 @@ +package onebot11 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "easybot/internal/store" +) + +var ErrAdapterNotFound = errors.New("adapter not found") + +type Manager struct { + store *store.Store + ctx context.Context + cancel context.CancelFunc + onEvent func(string, Event) + mu sync.RWMutex + items map[string]*Runtime + failures map[string]Status +} + +func NewManager(parent context.Context, db *store.Store, onEvent func(string, Event)) *Manager { + ctx, cancel := context.WithCancel(parent) + return &Manager{store: db, ctx: ctx, cancel: cancel, onEvent: onEvent, items: make(map[string]*Runtime), failures: make(map[string]Status)} +} + +func (m *Manager) StartEnabled(ctx context.Context) error { + items, err := m.store.List(ctx, "adapters") + if err != nil { + return err + } + for _, item := range items { + adapterType, _, err := parseRecord(item) + if err != nil { + if item.Enabled { + m.mu.Lock() + m.failures[item.ID] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)} + m.mu.Unlock() + } + continue + } + if adapterType != "Onebot11" || !item.Enabled { + continue + } + if err := m.Start(ctx, item.ID); err != nil { + continue + } + } + return nil +} + +func (m *Manager) Test(ctx context.Context, id string) (map[string]any, error) { + _, config, err := m.loadConfig(ctx, id) + if err != nil { + return nil, err + } + return TestConnection(ctx, config) +} + +func (m *Manager) Start(ctx context.Context, id string) error { + item, config, err := m.loadConfig(ctx, id) + if err != nil { + m.mu.Lock() + m.failures[id] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)} + m.mu.Unlock() + return err + } + if !item.Enabled { + return fmt.Errorf("adapter is disabled") + } + m.mu.Lock() + if existing := m.items[id]; existing != nil { + state := existing.Status().State + if state == StateConnected || state == StateStarting || state == StateReconnecting { + m.mu.Unlock() + return nil + } + } + runtime := NewRuntime(id, config, func(event Event) { + if m.onEvent != nil { + m.onEvent(id, event) + } + }) + m.items[id] = runtime + delete(m.failures, id) + m.mu.Unlock() + return runtime.Start(m.ctx) +} + +func (m *Manager) Stop(ctx context.Context, id string) error { + runtime := m.get(id) + if runtime == nil { + m.mu.Lock() + delete(m.failures, id) + m.mu.Unlock() + return nil + } + return runtime.Stop(ctx) +} + +func (m *Manager) Restart(ctx context.Context, id string) error { + _ = m.Stop(ctx, id) + return m.Start(ctx, id) +} + +func (m *Manager) Status(id string) (Status, error) { + if _, err := m.getRecord(context.Background(), id); err != nil { + return Status{}, err + } + if runtime := m.get(id); runtime != nil { + return runtime.Status(), nil + } + m.mu.RLock() + failed, hasFailure := m.failures[id] + m.mu.RUnlock() + if hasFailure { + return failed, nil + } + return Status{State: StateStopped}, nil +} + +func (m *Manager) Capabilities(ctx context.Context, id string) ([]string, error) { + if _, _, err := m.loadConfig(ctx, id); err != nil { + return nil, err + } + return append([]string(nil), Capabilities...), nil +} + +func (m *Manager) Send(ctx context.Context, id, messageType, peerID, message string) (string, error) { + runtime := m.get(id) + if runtime == nil { + return "", &APIError{Code: "adapter_offline", Message: "OneBot 适配器尚未启动", Retryable: true} + } + return runtime.Send(ctx, messageType, peerID, message) +} + +func (m *Manager) Groups(ctx context.Context, id string) ([]map[string]any, error) { + runtime := m.get(id) + if runtime == nil { + return nil, &APIError{Code: "adapter_offline", Message: "OneBot 适配器尚未启动", Retryable: true} + } + return runtime.Groups(ctx) +} + +func (m *Manager) Shutdown(ctx context.Context) { + m.cancel() + m.mu.RLock() + runtimes := make([]*Runtime, 0, len(m.items)) + for _, runtime := range m.items { + runtimes = append(runtimes, runtime) + } + m.mu.RUnlock() + for _, runtime := range runtimes { + _ = runtime.Stop(ctx) + } +} + +func (m *Manager) RuntimeCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.items) +} + +func (m *Manager) get(id string) *Runtime { + m.mu.RLock() + defer m.mu.RUnlock() + return m.items[id] +} + +func (m *Manager) loadConfig(ctx context.Context, id string) (store.Record, Config, error) { + item, err := m.getRecord(ctx, id) + if err != nil { + return store.Record{}, Config{}, err + } + adapterType, raw, err := parseRecord(item) + if err != nil { + return store.Record{}, Config{}, err + } + if adapterType != "Onebot11" { + return store.Record{}, Config{}, &APIError{Code: "adapter_type_unsupported", Message: "当前运行时仅支持 OneBot 11", Retryable: false} + } + config, err := ParseConfig(raw) + return item, config, err +} + +func (m *Manager) getRecord(ctx context.Context, id string) (store.Record, error) { + items, err := m.store.List(ctx, "adapters") + if err != nil { + return store.Record{}, err + } + for _, item := range items { + if item.ID == id { + return item, nil + } + } + return store.Record{}, ErrAdapterNotFound +} + +func parseRecord(item store.Record) (string, json.RawMessage, error) { + var detail struct { + AdapterType string `json:"adapterType"` + Config json.RawMessage `json:"config"` + } + if err := json.Unmarshal([]byte(item.Detail), &detail); err != nil { + return "", nil, &APIError{Code: "adapter_config_invalid", Message: "适配器配置格式无效", Retryable: false} + } + if detail.Config == nil { + detail.Config = json.RawMessage(item.Detail) + } + return detail.AdapterType, detail.Config, nil +} + +func (m *Manager) WaitForState(ctx context.Context, id string, state State) error { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if status, err := m.Status(id); err == nil && status.State == state { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/dev/internal/adapter/onebot11/runtime.go b/dev/internal/adapter/onebot11/runtime.go new file mode 100644 index 0000000..33ab33a --- /dev/null +++ b/dev/internal/adapter/onebot11/runtime.go @@ -0,0 +1,562 @@ +package onebot11 + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + logruntime "easybot/internal/runtime" + "github.com/gorilla/websocket" +) + +type Config struct { + WsURL string `json:"WsUrl"` + AccessToken string `json:"AccessToken"` + UseHeartBeat bool `json:"UseHeartBeat"` + HeartBeatInterval int `json:"HeartBeatInterval"` +} + +type State string + +const ( + StateStopped State = "stopped" + StateStarting State = "starting" + StateConnected State = "connected" + StateReconnecting State = "reconnecting" + StateStopping State = "stopping" + StateFailed State = "failed" +) + +type Status struct { + State State `json:"state"` + ConnectedAt *time.Time `json:"connectedAt,omitempty"` + LastReceivedAt *time.Time `json:"lastReceivedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + ReconnectCount uint64 `json:"reconnectCount"` + Received uint64 `json:"received"` + Sent uint64 `json:"sent"` + SelfID string `json:"selfId,omitempty"` + Nickname string `json:"nickname,omitempty"` + ConnectionID string `json:"connectionId,omitempty"` +} + +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + SubType string `json:"subType,omitempty"` + MetaEventType string `json:"metaEventType,omitempty"` + NoticeType string `json:"noticeType,omitempty"` + MessageID string `json:"messageId,omitempty"` + MessageType string `json:"messageType,omitempty"` + SelfID string `json:"selfId,omitempty"` + PeerID string `json:"peerId,omitempty"` + SenderID string `json:"senderId,omitempty"` + SenderName string `json:"senderName,omitempty"` + Content string `json:"content,omitempty"` + OccurredAt time.Time `json:"occurredAt"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +func (e *APIError) Error() string { return e.Message } + +var Capabilities = []string{"group_message_receive", "private_message_receive", "group_message_send", "private_message_send", "group_list", "heartbeat"} + +func ParseConfig(raw json.RawMessage) (Config, error) { + config := Config{} + if len(raw) == 0 || string(raw) == "null" || string(raw) == "{}" { + return Config{WsURL: "ws://localhost:3001/", HeartBeatInterval: 6000}, nil + } + if err := json.Unmarshal(raw, &config); err != nil { + return Config{}, &APIError{Code: "adapter_config_invalid", Message: "OneBot 11 配置不是有效 JSON", Retryable: false} + } + var aliases map[string]json.RawMessage + if err := json.Unmarshal(raw, &aliases); err == nil { + if config.WsURL == "" { + _ = json.Unmarshal(aliases["url"], &config.WsURL) + } + if config.AccessToken == "" { + _ = json.Unmarshal(aliases["accessToken"], &config.AccessToken) + } + } + if config.WsURL == "" { + config.WsURL = "ws://localhost:3001/" + } + if config.HeartBeatInterval == 0 { + config.HeartBeatInterval = 6000 + } + if err := config.Validate(); err != nil { + return Config{}, err + } + return config, nil +} + +func (c Config) Validate() error { + parsed, err := url.ParseRequestURI(strings.TrimSpace(c.WsURL)) + if err != nil || (parsed.Scheme != "ws" && parsed.Scheme != "wss") || parsed.Host == "" || parsed.User != nil { + return &APIError{Code: "adapter_config_invalid", Message: "WsUrl 必须是有效的 ws:// 或 wss:// 地址", Retryable: false} + } + if len(c.AccessToken) > 4096 { + return &APIError{Code: "adapter_config_invalid", Message: "AccessToken 长度超出限制", Retryable: false} + } + if c.HeartBeatInterval < 1000 || c.HeartBeatInterval > 300000 { + return &APIError{Code: "adapter_config_invalid", Message: "HeartBeatInterval 必须在 1000 到 300000 毫秒之间", Retryable: false} + } + return nil +} + +type Runtime struct { + id string + config Config + onEvent func(Event) + + mu sync.RWMutex + status Status + conn *websocket.Conn + writeMu sync.Mutex + pending map[string]chan response + ctx context.Context + cancel context.CancelFunc + done chan struct{} + started bool + sequence atomic.Uint64 +} + +type response struct { + packet map[string]any + err error +} + +func NewRuntime(id string, config Config, onEvent func(Event)) *Runtime { + return &Runtime{id: id, config: config, onEvent: onEvent, status: Status{State: StateStopped}, pending: make(map[string]chan response)} +} + +func TestConnection(ctx context.Context, config Config) (map[string]any, error) { + if err := config.Validate(); err != nil { + return nil, err + } + conn, responseHeader, err := dial(ctx, config) + if err != nil { + return nil, classifyDialError(err, responseHeader) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(8 * time.Second)) + echo := newEcho() + request, _ := json.Marshal(map[string]any{"action": "get_login_info", "params": map[string]any{}, "echo": echo}) + if err := conn.WriteMessage(websocket.TextMessage, request); err != nil { + return nil, &APIError{Code: "adapter_network_error", Message: "OneBot 已连接,但无法发送身份查询", Retryable: true} + } + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return nil, classifyReadError(err) + } + var packet map[string]any + if json.Unmarshal(raw, &packet) != nil || fmt.Sprint(packet["echo"]) != echo { + continue + } + if fmt.Sprint(packet["status"]) == "failed" { + return nil, classifyAPIResponse(packet) + } + if retcode, ok := packet["retcode"].(float64); ok && retcode != 0 { + return nil, classifyAPIResponse(packet) + } + data, _ := packet["data"].(map[string]any) + if dataString(data, "user_id") == "" { + return nil, &APIError{Code: "adapter_protocol_error", Message: "OneBot 身份响应缺少 user_id", Retryable: false} + } + return map[string]any{"connected": true, "authenticated": true, "selfId": dataString(data, "user_id"), "nickname": dataString(data, "nickname"), "capabilities": Capabilities}, nil + } +} + +func (r *Runtime) Start(parent context.Context) error { + r.mu.Lock() + if r.started { + state := r.status.State + r.mu.Unlock() + if state == StateConnected || state == StateStarting || state == StateReconnecting { + return nil + } + return errors.New("adapter is stopping") + } + r.ctx, r.cancel = context.WithCancel(parent) + r.done = make(chan struct{}) + r.started = true + r.status = Status{State: StateStarting, ConnectionID: newEcho()} + r.mu.Unlock() + go r.run() + return nil +} + +func (r *Runtime) Stop(ctx context.Context) error { + r.mu.Lock() + if !r.started { + r.mu.Unlock() + return nil + } + r.status.State = StateStopping + cancel, conn, done := r.cancel, r.conn, r.done + r.mu.Unlock() + if cancel != nil { + cancel() + } + if conn != nil { + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second)) + _ = conn.Close() + } + select { + case <-done: + r.mu.Lock() + r.started = false + r.conn = nil + r.status.State = StateStopped + r.mu.Unlock() + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *Runtime) Status() Status { + r.mu.RLock() + defer r.mu.RUnlock() + return r.status +} + +func (r *Runtime) Send(ctx context.Context, messageType, peerID, message string) (string, error) { + if messageType != "group" && messageType != "private" { + return "", &APIError{Code: "adapter_invalid_target", Message: "消息目标类型必须是 group 或 private", Retryable: false} + } + action := "send_group_msg" + params := map[string]any{"group_id": peerID, "message": message, "auto_escape": true} + if messageType == "private" { + action = "send_private_msg" + params = map[string]any{"user_id": peerID, "message": message, "auto_escape": true} + } + packet, err := r.call(ctx, action, params) + if err != nil { + return "", err + } + data, _ := packet["data"].(map[string]any) + r.mu.Lock() + r.status.Sent++ + r.mu.Unlock() + return dataString(data, "message_id"), nil +} + +func (r *Runtime) Groups(ctx context.Context) ([]map[string]any, error) { + packet, err := r.call(ctx, "get_group_list", map[string]any{}) + if err != nil { + return nil, err + } + data, _ := packet["data"].([]any) + groups := make([]map[string]any, 0, len(data)) + for _, value := range data { + if group, ok := value.(map[string]any); ok { + groups = append(groups, group) + } + } + return groups, nil +} + +func (r *Runtime) run() { + defer func() { + r.mu.Lock() + r.started = false + r.conn = nil + r.status.State = StateStopped + for echo, pending := range r.pending { + delete(r.pending, echo) + pending <- response{err: &APIError{Code: "adapter_disconnected", Message: "OneBot 连接已关闭", Retryable: true}} + } + r.mu.Unlock() + close(r.done) + }() + backoff := time.Second + for { + if r.ctx.Err() != nil { + return + } + conn, responseHeader, err := dial(r.ctx, r.config) + if err == nil { + r.mu.Lock() + r.conn = conn + r.mu.Unlock() + readErr := make(chan error, 1) + go r.readLoop(conn, readErr) + requestCtx, cancel := context.WithTimeout(r.ctx, 8*time.Second) + packet, callErr := r.call(requestCtx, "get_login_info", map[string]any{}) + cancel() + if callErr == nil { + data, _ := packet["data"].(map[string]any) + r.mu.Lock() + now := time.Now().UTC() + r.status.State = StateConnected + r.status.ConnectedAt = &now + r.status.LastError = "" + r.status.LastErrorCode = "" + r.status.SelfID = dataString(data, "user_id") + r.status.Nickname = dataString(data, "nickname") + r.mu.Unlock() + backoff = time.Second + select { + case <-r.ctx.Done(): + return + case err = <-readErr: + } + _ = conn.Close() + } else { + err = callErr + _ = conn.Close() + } + } else { + err = classifyDialError(err, responseHeader) + } + if r.ctx.Err() != nil { + return + } + r.mu.Lock() + r.conn = nil + r.status.State = StateReconnecting + r.status.ReconnectCount++ + r.status.LastError = safeMessage(err) + r.status.LastErrorCode = errorCode(err) + r.mu.Unlock() + select { + case <-r.ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + } +} + +func (r *Runtime) readLoop(conn *websocket.Conn, result chan<- error) { + if r.config.UseHeartBeat { + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(time.Duration(r.config.HeartBeatInterval+5000) * time.Millisecond)) + }) + } + for { + if r.config.UseHeartBeat { + _ = conn.SetReadDeadline(time.Now().Add(time.Duration(r.config.HeartBeatInterval+5000) * time.Millisecond)) + } + _, raw, err := conn.ReadMessage() + if err != nil { + select { + case result <- err: + default: + } + return + } + var packet map[string]any + if json.Unmarshal(raw, &packet) != nil { + continue + } + if echo, ok := packet["echo"].(string); ok { + r.mu.RLock() + pending := r.pending[echo] + r.mu.RUnlock() + if pending != nil { + if fmt.Sprint(packet["status"]) == "failed" { + pending <- response{err: classifyAPIResponse(packet)} + } else { + pending <- response{packet: packet} + } + } + continue + } + r.handleEvent(raw, packet) + } +} + +func (r *Runtime) handleEvent(raw []byte, packet map[string]any) { + r.mu.Lock() + now := time.Now().UTC() + r.status.Received++ + r.status.LastReceivedAt = &now + r.mu.Unlock() + postType, _ := packet["post_type"].(string) + event := Event{ + ID: firstString(packet, "message_id", "event_id"), + Type: postType, + SubType: dataString(packet, "sub_type"), + MetaEventType: dataString(packet, "meta_event_type"), + NoticeType: dataString(packet, "notice_type"), + MessageID: dataString(packet, "message_id"), + OccurredAt: eventTime(packet, now), + Raw: append(json.RawMessage(nil), raw...), + SelfID: dataString(packet, "self_id"), + } + if event.ID == "" { + event.ID = newEcho() + } + if postType == "message" { + event.MessageType = dataString(packet, "message_type") + if event.MessageType == "group" { + event.PeerID = dataString(packet, "group_id") + } else { + event.PeerID = dataString(packet, "user_id") + } + event.SenderID = dataString(packet, "user_id") + sender, _ := packet["sender"].(map[string]any) + event.SenderName = dataString(sender, "nickname") + event.Content = dataString(packet, "raw_message") + } + if r.onEvent != nil { + r.onEvent(event) + } +} + +func firstString(data map[string]any, keys ...string) string { + for _, key := range keys { + if value := dataString(data, key); value != "" { + return value + } + } + return "" +} + +func eventTime(packet map[string]any, fallback time.Time) time.Time { + value, ok := packet["time"].(float64) + if !ok || value <= 0 { + return fallback + } + return time.Unix(int64(value), 0).UTC() +} + +func (r *Runtime) call(ctx context.Context, action string, params map[string]any) (map[string]any, error) { + r.mu.RLock() + conn := r.conn + r.mu.RUnlock() + if conn == nil { + return nil, &APIError{Code: "adapter_offline", Message: "OneBot 11 连接未建立", Retryable: true} + } + echo := fmt.Sprintf("%s-%d", r.id, r.sequence.Add(1)) + result := make(chan response, 1) + r.mu.Lock() + r.pending[echo] = result + r.mu.Unlock() + defer func() { + r.mu.Lock() + delete(r.pending, echo) + r.mu.Unlock() + }() + data, err := json.Marshal(map[string]any{"action": action, "params": params, "echo": echo}) + if err != nil { + return nil, err + } + r.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + r.writeMu.Unlock() + if err != nil { + return nil, &APIError{Code: "adapter_network_error", Message: "OneBot 请求发送失败", Retryable: true} + } + select { + case <-ctx.Done(): + return nil, &APIError{Code: "adapter_request_timeout", Message: "OneBot API 请求超时", Retryable: true} + case response := <-result: + return response.packet, response.err + } +} + +func dial(ctx context.Context, config Config) (*websocket.Conn, *http.Response, error) { + dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second} + requestHeader := http.Header{} + if config.AccessToken != "" { + requestHeader.Set("Authorization", "Bearer "+config.AccessToken) + } + conn, response, err := dialer.DialContext(ctx, config.WsURL, requestHeader) + if conn != nil { + conn.SetReadLimit(1 << 20) + } + return conn, response, err +} + +func classifyDialError(err error, response *http.Response) error { + if response != nil && (response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden) { + return &APIError{Code: "adapter_auth_failed", Message: "OneBot 鉴权失败,请检查 AccessToken", Retryable: false} + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr + } + return &APIError{Code: "adapter_network_error", Message: "无法连接 OneBot WebSocket 服务", Retryable: true} +} + +func classifyReadError(err error) error { + if timeout, ok := err.(interface{ Timeout() bool }); ok && timeout.Timeout() { + return &APIError{Code: "adapter_request_timeout", Message: "OneBot WebSocket 请求超时", Retryable: true} + } + return &APIError{Code: "adapter_protocol_error", Message: "OneBot 未返回有效的身份查询响应", Retryable: true} +} + +func classifyAPIResponse(packet map[string]any) error { + message := fmt.Sprint(packet["message"]) + if message == "" || message == "" { + message = "OneBot API 返回失败" + } + code, _ := packet["retcode"].(float64) + switch int(code) { + case 401, 403, 1403: + return &APIError{Code: "adapter_auth_failed", Message: "OneBot 鉴权失败,请检查 AccessToken", Retryable: false} + case 429: + return &APIError{Code: "adapter_rate_limited", Message: "OneBot API 请求频率受限", Retryable: true} + default: + return &APIError{Code: "adapter_api_failed", Message: logruntime.Redact(message), Retryable: false} + } +} + +func safeMessage(err error) string { + if err == nil { + return "OneBot 连接已断开" + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.Message + } + return "OneBot 连接已断开" +} + +func errorCode(err error) string { + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.Code + } + return "adapter_network_error" +} + +func dataString(data map[string]any, key string) string { + if data == nil || data[key] == nil { + return "" + } + return fmt.Sprint(data[key]) +} + +func newEcho() string { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(value[:]) +} diff --git a/dev/internal/adapter/onebot11/runtime_test.go b/dev/internal/adapter/onebot11/runtime_test.go new file mode 100644 index 0000000..8aa1dc6 --- /dev/null +++ b/dev/internal/adapter/onebot11/runtime_test.go @@ -0,0 +1,215 @@ +package onebot11 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestConfigValidationAndLegacyFields(t *testing.T) { + config, err := ParseConfig(json.RawMessage(`{"url":"ws://127.0.0.1:3001/","accessToken":"secret"}`)) + if err != nil || config.WsURL != "ws://127.0.0.1:3001/" || config.AccessToken != "secret" { + t.Fatalf("legacy config parsed as %#v, error %v", config, err) + } + if _, err := ParseConfig(json.RawMessage(`{"WsUrl":"https://example.test/ws"}`)); err == nil { + t.Fatal("expected non-websocket URL to fail validation") + } +} + +func TestOneBotAPIErrorClassification(t *testing.T) { + if err := classifyAPIResponse(map[string]any{"retcode": float64(1403), "message": "credential rejected"}); errorCode(err) != "adapter_auth_failed" { + t.Fatalf("expected auth failure, got %v", err) + } + if err := classifyAPIResponse(map[string]any{"retcode": float64(429), "message": "too many requests"}); errorCode(err) != "adapter_rate_limited" { + t.Fatalf("expected rate limit, got %v", err) + } +} + +func TestTestConnectionReceivesIdentityAndUsesBearerToken(t *testing.T) { + var auth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + conn, err := (&websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + var request map[string]any + if err := conn.ReadJSON(&request); err != nil { + return + } + if request["action"] != "get_login_info" { + t.Errorf("unexpected first action: %#v", request) + } + _ = conn.WriteJSON(map[string]any{"status": "ok", "retcode": 0, "echo": request["echo"], "data": map[string]any{"user_id": 4242, "nickname": "mock bot"}}) + })) + defer server.Close() + config := Config{WsURL: "ws" + strings.TrimPrefix(server.URL, "http"), AccessToken: "mock-token", HeartBeatInterval: 6000} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + result, err := TestConnection(ctx, config) + if err != nil { + t.Fatal(err) + } + if result["selfId"] != "4242" || result["nickname"] != "mock bot" || auth != "Bearer mock-token" { + t.Fatalf("unexpected connection result %#v, auth %q", result, auth) + } +} + +func TestRuntimeReceiveSendAndStop(t *testing.T) { + events := make(chan Event, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + var request map[string]any + if err := conn.ReadJSON(&request); err != nil { + return + } + action := request["action"] + response := map[string]any{"status": "ok", "retcode": 0, "echo": request["echo"]} + switch action { + case "get_login_info": + response["data"] = map[string]any{"user_id": 7, "nickname": "runtime bot"} + case "send_group_msg": + response["data"] = map[string]any{"message_id": 88} + } + if conn.WriteJSON(response) != nil { + return + } + if action == "get_login_info" { + _ = conn.WriteJSON(map[string]any{"time": 1710000000, "message_id": 1234, "post_type": "message", "message_type": "group", "self_id": 7, "group_id": 99, "user_id": 5, "raw_message": "hello", "sender": map[string]any{"nickname": "tester"}}) + } + } + })) + defer server.Close() + runtime := NewRuntime("adapter-1", Config{WsURL: "ws" + strings.TrimPrefix(server.URL, "http"), HeartBeatInterval: 6000}, func(event Event) { events <- event }) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := waitForStatus(ctx, runtime, StateConnected); err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event.Type != "message" || event.MessageID != "1234" || event.MessageType != "group" || event.PeerID != "99" || event.Content != "hello" || event.OccurredAt.Unix() != 1710000000 { + t.Fatalf("unexpected normalized event %#v", event) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for OneBot event") + } + messageID, err := runtime.Send(ctx, "group", "99", "outbound") + if err != nil || messageID != "88" { + t.Fatalf("send returned id=%q, error=%v", messageID, err) + } + stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second) + defer stopCancel() + if err := runtime.Stop(stopCtx); err != nil { + t.Fatal(err) + } + if status := runtime.Status(); status.State != StateStopped || status.Sent != 1 || status.Received == 0 { + t.Fatalf("unexpected stopped status %#v", status) + } +} + +func TestRuntimeBackoffReconnects(t *testing.T) { + var connections sync.WaitGroup + var count int + var mu sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}).Upgrade(w, r, nil) + if err != nil { + return + } + mu.Lock() + count++ + current := count + mu.Unlock() + connections.Add(1) + defer connections.Done() + defer conn.Close() + var request map[string]any + if conn.ReadJSON(&request) != nil { + return + } + _ = conn.WriteJSON(map[string]any{"status": "ok", "retcode": 0, "echo": request["echo"], "data": map[string]any{"user_id": 7, "nickname": "bot"}}) + if current == 1 { + time.Sleep(50 * time.Millisecond) + return + } + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + runtime := NewRuntime("adapter-reconnect", Config{WsURL: "ws" + strings.TrimPrefix(server.URL, "http"), HeartBeatInterval: 6000}, nil) + if err := runtime.Start(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + for { + status := runtime.Status() + mu.Lock() + accepted := count + mu.Unlock() + if status.State == StateConnected && status.ReconnectCount > 0 && accepted >= 2 { + break + } + select { + case <-ctx.Done(): + t.Fatalf("runtime did not reconnect: status=%#v connections=%d", status, accepted) + case <-time.After(20 * time.Millisecond): + } + } + stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second) + defer stopCancel() + if err := runtime.Stop(stopCtx); err != nil { + t.Fatal(err) + } +} + +func newOneBotMock(t *testing.T, handle func(*websocket.Conn)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mock-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + conn, err := (&websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + handle(conn) + })) +} + +func waitForStatus(ctx context.Context, runtime *Runtime, target State) error { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if runtime.Status().State == target { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/dev/internal/web/web.go b/dev/internal/web/web.go index e594f42..e6655cf 100644 --- a/dev/internal/web/web.go +++ b/dev/internal/web/web.go @@ -1,6 +1,7 @@ package web import ( + "context" "crypto/rand" "crypto/sha256" "crypto/subtle" @@ -21,17 +22,19 @@ import ( "sync" "time" + "easybot/internal/adapter/onebot11" "easybot/internal/runtime" "easybot/internal/store" ) type server struct { - store *store.Store - logger *slog.Logger - logs *runtime.LogBuffer - logWriter *runtime.AsyncLogWriter - loginMu sync.Mutex - loginAttempts map[string]loginAttempt + store *store.Store + logger *slog.Logger + logs *runtime.LogBuffer + logWriter *runtime.AsyncLogWriter + adapterManager *onebot11.Manager + loginMu sync.Mutex + loginAttempts map[string]loginAttempt } type loginAttempt struct { @@ -43,7 +46,11 @@ const sessionCookie = "easybot_session" const sessionLifetime = 12 * time.Hour func New(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, writers ...*runtime.AsyncLogWriter) http.Handler { - s := &server{store: db, logger: logger, logs: logs, loginAttempts: make(map[string]loginAttempt)} + return NewWithAdapterManager(db, logger, logs, nil, writers...) +} + +func NewWithAdapterManager(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, adapters *onebot11.Manager, writers ...*runtime.AsyncLogWriter) http.Handler { + s := &server{store: db, logger: logger, logs: logs, adapterManager: adapters, loginAttempts: make(map[string]loginAttempt)} if len(writers) > 0 { s.logWriter = writers[0] } @@ -66,6 +73,12 @@ func New(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, writers mux.HandleFunc("PUT /api/adapter/{id}", s.updateAdapter) mux.HandleFunc("POST /api/adapter/{id}/start", s.adapterStart) mux.HandleFunc("POST /api/adapter/{id}/stop", s.adapterStop) + mux.HandleFunc("POST /api/adapter/{id}/test", s.adapterTest) + mux.HandleFunc("POST /api/adapter/{id}/restart", s.adapterRestart) + mux.HandleFunc("GET /api/adapter/{id}/status", s.adapterStatus) + mux.HandleFunc("GET /api/adapter/{id}/capabilities", s.adapterCapabilities) + mux.HandleFunc("POST /api/adapter/{id}/send", s.adapterSend) + mux.HandleFunc("GET /api/adapter/{id}/groups", s.adapterGroups) mux.HandleFunc("GET /api/logs", s.recentLogs) mux.HandleFunc("GET /api/logs/status", s.logStatus) mux.HandleFunc("PUT /api/logs/settings", s.updateLogSettings) @@ -262,6 +275,11 @@ func (s *server) listRecords(w http.ResponseWriter, r *http.Request) { http.Error(w, "database query failed", http.StatusInternalServerError) return } + if kind == "adapters" { + for i := range items { + items[i] = publicAdapterRecord(items[i]) + } + } writeJSON(w, http.StatusOK, items) } @@ -275,6 +293,9 @@ func (s *server) createRecord(w http.ResponseWriter, r *http.Request) { writeStoreError(w, err) return } + if item.Kind == "adapters" { + item = publicAdapterRecord(item) + } writeJSON(w, http.StatusCreated, item) } @@ -289,6 +310,9 @@ func (s *server) updateRecord(w http.ResponseWriter, r *http.Request) { writeStoreError(w, err) return } + if item.Kind == "adapters" { + item = publicAdapterRecord(item) + } writeJSON(w, http.StatusOK, item) } @@ -304,9 +328,6 @@ func (s *server) deleteRecord(w http.ResponseWriter, r *http.Request) { func (s *server) adapterTypes(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, []map[string]string{ {"adapterType": "Onebot11", "displayName": "OneBot 11", "platformName": "onebot11"}, - {"adapterType": "Milky", "displayName": "Milky", "platformName": "milky"}, - {"adapterType": "Telegram", "displayName": "Telegram", "platformName": "telegram"}, - {"adapterType": "OpenQQ", "displayName": "OpenQQ", "platformName": "openqq"}, }) } @@ -316,6 +337,9 @@ func (s *server) adapters(w http.ResponseWriter, r *http.Request) { http.Error(w, "database query failed", http.StatusInternalServerError) return } + for i := range items { + items[i] = publicAdapterRecord(items[i]) + } writeJSON(w, http.StatusOK, items) } @@ -328,8 +352,8 @@ func (s *server) createAdapter(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &body) { return } - if body.AdapterType != "Onebot11" && body.AdapterType != "Milky" && body.AdapterType != "Telegram" && body.AdapterType != "OpenQQ" { - writeJSON(w, http.StatusBadRequest, map[string]string{"message": "unsupported adapter type"}) + if body.AdapterType != "Onebot11" { + writeJSON(w, http.StatusNotImplemented, map[string]string{"message": "当前主程序仅支持 OneBot 11 适配器"}) return } if body.DisplayName == "" { @@ -343,13 +367,19 @@ func (s *server) createAdapter(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"message": "adapter config must be valid JSON"}) return } + if body.AdapterType == "Onebot11" { + if _, err := onebot11.ParseConfig(config); err != nil { + writeAdapterError(w, err) + return + } + } detail, _ := json.Marshal(map[string]any{"adapterType": body.AdapterType, "config": config}) item, err := s.store.Save(r.Context(), store.Record{Kind: "adapters", Name: body.DisplayName, Detail: string(detail), Enabled: true}) if err != nil { writeStoreError(w, err) return } - writeJSON(w, http.StatusCreated, item) + writeJSON(w, http.StatusCreated, publicAdapterRecord(item)) } func (s *server) updateAdapter(w http.ResponseWriter, r *http.Request) { @@ -360,20 +390,270 @@ func (s *server) updateAdapter(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &body) { return } - item, err := s.store.Save(r.Context(), store.Record{ID: r.PathValue("id"), Kind: "adapters", Name: body.DisplayName, Detail: body.ConfigJSON, Enabled: true}) + old, err := s.adapterRecord(r.Context(), r.PathValue("id")) if err != nil { writeStoreError(w, err) return } - writeJSON(w, http.StatusOK, item) + var oldDetail struct { + AdapterType string `json:"adapterType"` + Config json.RawMessage `json:"config"` + } + if err := json.Unmarshal([]byte(old.Detail), &oldDetail); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "stored adapter config is invalid"}) + return + } + var config json.RawMessage + if !json.Valid([]byte(body.ConfigJSON)) || json.Unmarshal([]byte(body.ConfigJSON), &config) != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "adapter config must be valid JSON"}) + return + } + config = preserveAccessToken(oldDetail.Config, config) + if oldDetail.AdapterType == "Onebot11" { + if _, err := onebot11.ParseConfig(config); err != nil { + writeAdapterError(w, err) + return + } + } + detail, _ := json.Marshal(map[string]any{"adapterType": oldDetail.AdapterType, "config": config}) + item, err := s.store.Save(r.Context(), store.Record{ID: old.ID, Kind: "adapters", Name: body.DisplayName, Detail: string(detail), Enabled: old.Enabled}) + if err != nil { + writeStoreError(w, err) + return + } + if s.adapterManager != nil { + if status, statusErr := s.adapterManager.Status(item.ID); statusErr == nil && status.State != onebot11.StateStopped { + if restartErr := s.adapterManager.Restart(r.Context(), item.ID); restartErr != nil { + s.logger.Warn("adapter restart after config update failed", "adapter_id", item.ID, "error", restartErr) + } + } + } + writeJSON(w, http.StatusOK, publicAdapterRecord(item)) +} + +func (s *server) adapterStart(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + if err := s.adapterManager.Start(r.Context(), r.PathValue("id")); err != nil { + writeAdapterError(w, err) + return + } + status, _ := s.adapterManager.Status(r.PathValue("id")) + writeJSON(w, http.StatusAccepted, status) +} + +func (s *server) adapterStop(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := s.adapterManager.Stop(ctx, r.PathValue("id")); err != nil { + writeAdapterError(w, err) + return + } + status, _ := s.adapterManager.Status(r.PathValue("id")) + writeJSON(w, http.StatusOK, status) +} + +func (s *server) adapterRestart(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + if err := s.adapterManager.Restart(r.Context(), r.PathValue("id")); err != nil { + writeAdapterError(w, err) + return + } + status, _ := s.adapterManager.Status(r.PathValue("id")) + writeJSON(w, http.StatusAccepted, status) +} + +func (s *server) adapterTest(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + result, err := s.adapterManager.Test(ctx, r.PathValue("id")) + if err != nil { + writeAdapterError(w, err) + return + } + writeJSON(w, http.StatusOK, result) } -func (s *server) adapterStart(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusNotImplemented, map[string]string{"message": "Adapter protocol runtimes are not implemented in this build."}) +func (s *server) adapterStatus(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + status, err := s.adapterManager.Status(r.PathValue("id")) + if err != nil { + writeAdapterError(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (s *server) adapterCapabilities(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + capabilities, err := s.adapterManager.Capabilities(r.Context(), r.PathValue("id")) + if err != nil { + writeAdapterError(w, err) + return + } + writeJSON(w, http.StatusOK, capabilities) +} + +func (s *server) adapterSend(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + var body struct { + MessageType string `json:"messageType"` + PeerID string `json:"peerId"` + Message string `json:"message"` + } + if !decode(w, r, &body) { + return + } + if body.PeerID == "" || strings.TrimSpace(body.Message) == "" || len(body.Message) > 4000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "peerId and a message of 1-4000 bytes are required"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + messageID, err := s.adapterManager.Send(ctx, r.PathValue("id"), body.MessageType, body.PeerID, body.Message) + if err != nil { + writeAdapterError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"messageId": messageID}) +} + +func (s *server) adapterGroups(w http.ResponseWriter, r *http.Request) { + if s.adapterManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Adapter runtime is unavailable."}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + groups, err := s.adapterManager.Groups(ctx, r.PathValue("id")) + if err != nil { + writeAdapterError(w, err) + return + } + writeJSON(w, http.StatusOK, groups) +} + +func (s *server) adapterRecord(ctx context.Context, id string) (store.Record, error) { + items, err := s.store.List(ctx, "adapters") + if err != nil { + return store.Record{}, err + } + for _, item := range items { + if item.ID == id { + return item, nil + } + } + return store.Record{}, sql.ErrNoRows +} + +func publicAdapterRecord(item store.Record) store.Record { + var detail struct { + AdapterType string `json:"adapterType"` + Config json.RawMessage `json:"config"` + } + if json.Unmarshal([]byte(item.Detail), &detail) != nil { + item.Detail = `{"adapterType":"unknown","config":{},"configError":true}` + return item + } + var config map[string]json.RawMessage + if json.Unmarshal(detail.Config, &config) != nil { + publicDetail, _ := json.Marshal(map[string]any{"adapterType": detail.AdapterType, "config": map[string]any{}, "configError": true}) + item.Detail = string(publicDetail) + return item + } + var token string + _ = json.Unmarshal(config["AccessToken"], &token) + if token == "" { + _ = json.Unmarshal(config["accessToken"], &token) + } + if token != "" { + config["AccessToken"] = json.RawMessage(`""`) + delete(config, "accessToken") + config["accessTokenConfigured"] = json.RawMessage(`true`) + } + publicConfig, _ := json.Marshal(config) + publicDetail, _ := json.Marshal(map[string]any{"adapterType": detail.AdapterType, "config": json.RawMessage(publicConfig)}) + item.Detail = string(publicDetail) + return item } -func (s *server) adapterStop(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusNotImplemented, map[string]string{"message": "No adapter runtime is active."}) +func preserveAccessToken(oldConfig, newConfig json.RawMessage) json.RawMessage { + var oldValues, newValues map[string]json.RawMessage + if json.Unmarshal(oldConfig, &oldValues) != nil || json.Unmarshal(newConfig, &newValues) != nil { + return newConfig + } + var oldToken, newToken string + _ = json.Unmarshal(oldValues["AccessToken"], &oldToken) + if oldToken == "" { + _ = json.Unmarshal(oldValues["accessToken"], &oldToken) + } + _ = json.Unmarshal(newValues["AccessToken"], &newToken) + if newToken == "" { + _ = json.Unmarshal(newValues["accessToken"], &newToken) + } + var configured bool + _ = json.Unmarshal(newValues["accessTokenConfigured"], &configured) + if oldToken != "" && (newToken == "" || newToken == "********" || configured) { + newValues["AccessToken"] = oldValues["AccessToken"] + if len(newValues["AccessToken"]) == 0 { + newValues["AccessToken"] = oldValues["accessToken"] + } + delete(newValues, "accessToken") + } + delete(newValues, "accessTokenConfigured") + result, err := json.Marshal(newValues) + if err != nil { + return newConfig + } + return result +} + +func writeAdapterError(w http.ResponseWriter, err error) { + status := http.StatusBadGateway + var apiErr *onebot11.APIError + if errors.As(err, &apiErr) { + switch apiErr.Code { + case "adapter_config_invalid", "adapter_invalid_target": + status = http.StatusBadRequest + case "adapter_auth_failed": + status = http.StatusUnauthorized + case "adapter_rate_limited": + status = http.StatusTooManyRequests + case "adapter_offline": + status = http.StatusServiceUnavailable + case "adapter_type_unsupported": + status = http.StatusNotImplemented + } + } else if errors.Is(err, onebot11.ErrAdapterNotFound) || errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + code := "adapter_error" + if errors.As(err, &apiErr) { + code = apiErr.Code + } + writeJSON(w, status, map[string]any{"message": err.Error(), "code": code}) } func (s *server) security(next http.Handler) http.Handler { diff --git a/dev/internal/web/web_test.go b/dev/internal/web/web_test.go index 6abb940..4e0577b 100644 --- a/dev/internal/web/web_test.go +++ b/dev/internal/web/web_test.go @@ -1,6 +1,8 @@ package web import ( + "context" + "encoding/json" "io" "log/slog" "net/http" @@ -8,6 +10,7 @@ import ( "strings" "testing" + "easybot/internal/adapter/onebot11" "easybot/internal/runtime" "easybot/internal/store" ) @@ -54,6 +57,14 @@ func TestRecordCRUDAndApiKey(t *testing.T) { if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `Onebot11`) { t.Fatalf("adapter create returned %d: %s", response.Code, response.Body.String()) } + request = httptest.NewRequest(http.MethodPost, "/api/adapter/create", strings.NewReader(`{"adapterType":"Telegram","displayName":"unsupported","configJson":"{}"}`)) + request.Header.Set("Authorization", "Bearer test-key") + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotImplemented { + t.Fatalf("unsupported adapter returned %d: %s", response.Code, response.Body.String()) + } request = httptest.NewRequest(http.MethodGet, "/api/logs?limit=10", nil) request.Header.Set("Authorization", "Bearer test-key") response = httptest.NewRecorder() @@ -78,6 +89,78 @@ func TestRecordCRUDAndApiKey(t *testing.T) { } } +func TestAdapterCredentialsAreRedactedAndPreservedOnUpdate(t *testing.T) { + db, err := store.Open(t.TempDir() + "/easybot.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + t.Setenv("EASYBOT_API_KEY", "test-key") + handler := New(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20)) + request := httptest.NewRequest(http.MethodPost, "/api/adapter/create", strings.NewReader(`{"adapterType":"Onebot11","displayName":"Mock","configJson":"{\"url\":\"ws://127.0.0.1:3001/\",\"accessToken\":\"never-return-this\"}"}`)) + request.Header.Set("Authorization", "Bearer test-key") + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated || strings.Contains(response.Body.String(), "never-return-this") { + t.Fatalf("create leaked secret or failed: %d %s", response.Code, response.Body.String()) + } + var created store.Record + if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/adapter/list", "/api/records?kind=adapters"} { + request = httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("Authorization", "Bearer test-key") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "never-return-this") { + t.Fatalf("%s leaked secret or failed: %d %s", path, response.Code, response.Body.String()) + } + } + request = httptest.NewRequest(http.MethodPut, "/api/adapter/"+created.ID, strings.NewReader(`{"displayName":"Mock updated","configJson":"{\"url\":\"ws://127.0.0.1:3002/\",\"accessTokenConfigured\":true}"}`)) + request.Header.Set("Authorization", "Bearer test-key") + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "never-return-this") { + t.Fatalf("update failed or leaked secret: %d %s", response.Code, response.Body.String()) + } + items, err := db.List(context.Background(), "adapters") + if err != nil || len(items) != 1 { + t.Fatalf("adapter record missing: %v", err) + } + var detail struct { + Config json.RawMessage `json:"config"` + } + if err := json.Unmarshal([]byte(items[0].Detail), &detail); err != nil { + t.Fatal(err) + } + config, err := onebot11.ParseConfig(detail.Config) + if err != nil || config.AccessToken != "never-return-this" || config.WsURL != "ws://127.0.0.1:3002/" { + t.Fatalf("update failed to preserve the token: %#v %v", config, err) + } +} + +func TestAdapterAPIReportsStoppedAndRejectsUnknownID(t *testing.T) { + db, err := store.Open(t.TempDir() + "/easybot.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + t.Setenv("EASYBOT_API_KEY", "test-key") + manager := onebot11.NewManager(context.Background(), db, nil) + defer manager.Shutdown(context.Background()) + handler := NewWithAdapterManager(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20), manager) + request := httptest.NewRequest(http.MethodGet, "/api/adapter/missing/status", nil) + request.Header.Set("Authorization", "Bearer test-key") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("missing adapter status returned %d: %s", response.Code, response.Body.String()) + } +} + func TestInitialSetupLoginAndSessionAuthorization(t *testing.T) { db, err := store.Open(t.TempDir() + "/easybot.db") if err != nil { diff --git a/dev/web/src/App.vue b/dev/web/src/App.vue index 64714dd..eb35d44 100644 --- a/dev/web/src/App.vue +++ b/dev/web/src/App.vue @@ -7,6 +7,7 @@ type Entry = { id: string; kind: Kind; name: string; detail: string; enabled: bo type LogEntry = { id: string; time: string; level: string; source: string; message: string; requestId?: string } type LogPage = { items: LogEntry[]; total: number; limit: number; offset: number } type LogStatus = { persistent: boolean; retentionDays: number; maxRows: number; droppedWrites: number; failedWrites: number } +type AdapterStatus = { state: 'stopped' | 'starting' | 'connected' | 'reconnecting' | 'stopping' | 'failed'; lastError?: string; reconnectCount: number; selfId?: string; nickname?: string } const nav = [ { id: 'overview', label: '概览', icon: LayoutDashboard, group: '概览' }, { id: 'adapters', label: '平台适配器', icon: Radio, group: '接入', kind: 'adapters' as Kind }, @@ -20,13 +21,14 @@ const nav = [ { id: 'logs', label: '运行日志', icon: Activity, group: '运维' }, ] const selected = ref('overview'), entries = ref([]), adapters = ref([]), servers = ref([]), logs = ref([]), counts = ref>({}), loading = ref(false), error = ref(''), toast = ref(''), query = ref(''), modal = ref(false), apiOnline = ref(false), updatedAt = ref('') +const adapterStatuses = ref>({}) const logQuery = ref(''), logLevel = ref(''), logComponent = ref(''), logRequestID = ref(''), logFrom = ref(''), logTo = ref(''), logsTotal = ref(0), logsOffset = ref(0), logStatus = ref(null), logPersistence = ref(true), logRetentionDays = ref(30), logMaxRows = ref(100000), savingLogSettings = ref(false) const authChecked = ref(false), authenticated = ref(false), setupRequired = ref(false), authBusy = ref(false), authError = ref(''), authUsername = ref(''), authPassword = ref('') const editing = ref(null), name = ref(''), detail = ref('{}'), enabled = ref(true), adapterType = ref('Onebot11') const current = computed(() => nav.find(item => item.id === selected.value)!), kind = computed(() => current.value.kind), title = computed(() => current.value.label) const filtered = computed(() => entries.value.filter(item => `${item.name} ${item.detail}`.toLowerCase().includes(query.value.toLowerCase()))) const groups = ['概览', '接入', '消息', '玩家', '自动化', '运维'] -const descriptions: Record = { adapters: '平台连接配置。连接测试和运行时生命周期仍在移植。', servers: 'Minecraft 实例配置。桥接端连接能力仍在移植。', players: '玩家资料与社交账号绑定记录。', groups: '适配器发现的社交群组。', whitelist: '服务器准入名单。', blacklist: '封禁账号与备注。', commands: '自定义命令定义。命令执行引擎仍在移植。', settings: '实例级运行参数。', 'sync-rules': '群与服务器之间的消息路由规则。' } +const descriptions: Record = { adapters: 'OneBot 11 正向 WebSocket 连接、运行诊断与消息 API。', servers: 'Minecraft 实例配置。桥接端连接能力仍在移植。', players: '玩家资料与社交账号绑定记录。', groups: '适配器发现的社交群组。', whitelist: '服务器准入名单。', blacklist: '封禁账号与备注。', commands: '自定义命令定义。命令执行引擎仍在移植。', settings: '实例级运行参数。', 'sync-rules': '群与服务器之间的消息路由规则。' } const recentActivity = computed(() => [...logs.value].sort((a, b) => Date.parse(b.time) - Date.parse(a.time)).slice(0, 5)) const dashboardStats = computed(() => [ { label: '平台适配器', value: counts.value.adapters ?? 0, note: '已保存配置', icon: Radio, tone: 'mint' }, @@ -38,27 +40,30 @@ function formatAdapterType(item: Entry) { try { return JSON.parse(item.detail || async function api(url: string, init?: RequestInit): Promise { const key = sessionStorage.getItem('easybotApiKey'); const response = await fetch(url, { ...init, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}), ...init?.headers } }); if (!response.ok) { if (response.status === 401 && !url.startsWith('/api/auth/')) authenticated.value = false; const body = await response.json().catch(() => ({})); throw new Error(body.message ?? `请求失败 (${response.status})`) } if (response.status === 204) return undefined as T; return response.json() as Promise } async function loadLogs(offset = logsOffset.value) { const params = new URLSearchParams({ limit: '100', offset: String(offset) }); if (logLevel.value) params.set('level', logLevel.value); if (logComponent.value) params.set('component', logComponent.value); if (logRequestID.value.trim()) params.set('requestId', logRequestID.value.trim()); if (logQuery.value.trim()) params.set('q', logQuery.value.trim()); if (logFrom.value) params.set('from', new Date(logFrom.value).toISOString()); if (logTo.value) params.set('to', new Date(logTo.value).toISOString()); const [page, status] = await Promise.all([api(`/api/logs?${params}`), api('/api/logs/status')]); logs.value = page.items; logsTotal.value = page.total; logsOffset.value = page.offset; if (!logStatus.value) { logPersistence.value = status.persistent; logRetentionDays.value = status.retentionDays; logMaxRows.value = status.maxRows } logStatus.value = status } async function saveLogSettings() { savingLogSettings.value = true; error.value = ''; try { const saved = await api('/api/logs/settings', { method: 'PUT', body: JSON.stringify({ persistent: logPersistence.value, retentionDays: logRetentionDays.value, maxRows: logMaxRows.value }) }); logStatus.value = saved; toast.value = '日志策略已保存'; setTimeout(() => toast.value = '', 2200); await loadLogs(0) } catch (e) { error.value = e instanceof Error ? e.message : '日志策略保存失败' } finally { savingLogSettings.value = false } } -async function load() { if (selected.value === 'logs') { loading.value = true; error.value = ''; try { await loadLogs(0) } catch (e) { error.value = e instanceof Error ? e.message : '加载失败' } finally { loading.value = false }; return } if (!kind.value) return; loading.value = true; error.value = ''; try { entries.value = await api(`/api/records?kind=${kind.value}`) } catch (e) { error.value = e instanceof Error ? e.message : '加载失败' } finally { loading.value = false } } -async function dashboard() { try { const [summary, adapterItems, serverItems, recent] = await Promise.all([api<{ counts: Record }>('/api/dashboard/data'), api('/api/adapter/list'), api('/api/records?kind=servers'), api('/api/logs?limit=100')]); counts.value = summary.counts; adapters.value = adapterItems; servers.value = serverItems; logs.value = recent.items; apiOnline.value = true; updatedAt.value = new Date().toLocaleTimeString('zh-CN'); error.value = '' } catch { apiOnline.value = false; updatedAt.value = '' } } +async function loadAdapterStatuses() { const next: Record = {}; await Promise.all(adapters.value.map(async item => { try { next[item.id] = await api(`/api/adapter/${item.id}/status`) } catch { next[item.id] = { state: 'failed', reconnectCount: 0 } } })); adapterStatuses.value = next } +async function load() { if (selected.value === 'logs') { loading.value = true; error.value = ''; try { await loadLogs(0) } catch (e) { error.value = e instanceof Error ? e.message : '加载失败' } finally { loading.value = false }; return } if (!kind.value) return; loading.value = true; error.value = ''; try { if (selected.value === 'adapters') { entries.value = await api('/api/adapter/list'); adapters.value = entries.value; await loadAdapterStatuses() } else entries.value = await api(`/api/records?kind=${kind.value}`) } catch (e) { error.value = e instanceof Error ? e.message : '加载失败' } finally { loading.value = false } } +async function dashboard() { try { const [summary, adapterItems, serverItems, recent] = await Promise.all([api<{ counts: Record }>('/api/dashboard/data'), api('/api/adapter/list'), api('/api/records?kind=servers'), api('/api/logs?limit=100')]); counts.value = summary.counts; adapters.value = adapterItems; servers.value = serverItems; logs.value = recent.items; await loadAdapterStatuses(); apiOnline.value = true; updatedAt.value = new Date().toLocaleTimeString('zh-CN'); error.value = '' } catch { apiOnline.value = false; updatedAt.value = '' } } async function initializeConsole() { try { const setup = await api<{ initialized: boolean }>('/api/setup/status'); setupRequired.value = !setup.initialized; if (setup.initialized) { const session = await api<{ authenticated: boolean }>('/api/auth/session'); authenticated.value = session.authenticated || Boolean(sessionStorage.getItem('easybotApiKey')) } authChecked.value = true; if (authenticated.value) { void dashboard(); void load() } } catch (e) { authError.value = e instanceof Error ? e.message : '无法连接 Go API'; authChecked.value = true } } async function submitAuth() { authBusy.value = true; authError.value = ''; try { await api(setupRequired.value ? '/api/setup/admin' : '/api/auth/login', { method: 'POST', body: JSON.stringify({ username: authUsername.value, password: authPassword.value }) }); authPassword.value = ''; authenticated.value = true; setupRequired.value = false; void dashboard(); void load() } catch (e) { authError.value = e instanceof Error ? e.message : '身份验证失败' } finally { authBusy.value = false } } watch(selected, () => { query.value = ''; if (!authenticated.value) return; if (selected.value === 'overview') void dashboard(); else void load() }) let logRefresh: number | undefined -onMounted(() => { void initializeConsole(); logRefresh = window.setInterval(() => { if (!apiOnline.value || !authenticated.value) return; if (selected.value === 'overview') void dashboard(); else if (selected.value === 'logs') void loadLogs().catch(() => {}) }, 15000) }) +onMounted(() => { void initializeConsole(); logRefresh = window.setInterval(() => { if (!apiOnline.value || !authenticated.value) return; if (selected.value === 'overview') void dashboard(); else if (selected.value === 'logs') void loadLogs().catch(() => {}); else if (selected.value === 'adapters') void load().catch(() => {}) }, 15000) }) onUnmounted(() => { if (logRefresh) window.clearInterval(logRefresh) }) function openDocs() { window.open('https://docs.inectar.cn/docs/easybot/intro', '_blank', 'noopener') } function configureKey() { const value = prompt('输入 EASYBOT_API_KEY;留空可清除当前会话密钥。', sessionStorage.getItem('easybotApiKey') ?? ''); if (value === null) return; if (value) sessionStorage.setItem('easybotApiKey', value); else sessionStorage.removeItem('easybotApiKey'); error.value = ''; void dashboard(); void load() } async function signOut() { try { await api('/api/auth/logout', { method: 'POST' }) } catch { /* Local state still needs to close if the service is unavailable. */ } sessionStorage.removeItem('easybotApiKey'); authenticated.value = false; authUsername.value = ''; authPassword.value = '' } function newEntry() { editing.value = null; name.value = ''; enabled.value = true; adapterType.value = 'Onebot11'; detail.value = kind.value === 'adapters' ? '{\n "url": "ws://127.0.0.1:3001"\n}' : '{}'; modal.value = true } -function edit(item: Entry) { editing.value = item; name.value = item.name; detail.value = item.detail; enabled.value = item.enabled; modal.value = true } +function edit(item: Entry) { editing.value = item; name.value = item.name; try { const parsed = JSON.parse(item.detail || '{}'); detail.value = JSON.stringify(parsed.config ?? parsed, null, 2); adapterType.value = parsed.adapterType ?? 'Onebot11' } catch { detail.value = item.detail || '{}' }; enabled.value = item.enabled; modal.value = true } async function save() { if (!kind.value || !name.value.trim()) return; error.value = ''; try { JSON.parse(detail.value); let saved: Entry if (kind.value === 'adapters') saved = editing.value ? await api(`/api/adapter/${editing.value.id}`, { method: 'PUT', body: JSON.stringify({ displayName: name.value.trim(), configJson: detail.value }) }) : await api('/api/adapter/create', { method: 'POST', body: JSON.stringify({ adapterType: adapterType.value, displayName: name.value.trim(), configJson: detail.value }) }) else { const body = { kind: kind.value, name: name.value.trim(), detail: detail.value, enabled: enabled.value }; saved = editing.value ? await api(`/api/records/${kind.value}/${editing.value.id}`, { method: 'PUT', body: JSON.stringify(body) }) : await api('/api/records', { method: 'POST', body: JSON.stringify(body) }) } - entries.value = editing.value ? entries.value.map(item => item.id === saved.id ? saved : item) : [saved, ...entries.value]; modal.value = false; toast.value = editing.value ? '配置已保存' : '配置已创建'; setTimeout(() => toast.value = '', 2200); void dashboard() + entries.value = editing.value ? entries.value.map(item => item.id === saved.id ? saved : item) : [saved, ...entries.value]; modal.value = false; toast.value = editing.value ? '配置已保存' : '配置已创建'; setTimeout(() => toast.value = '', 2200); void dashboard(); if (selected.value === 'adapters') void load() } catch (e) { error.value = e instanceof Error ? e.message : '保存失败' } } async function remove(item: Entry) { if (!kind.value || !confirm(`删除“${item.name}”?`)) return; try { await api(`/api/records/${kind.value}/${item.id}`, { method: 'DELETE' }); entries.value = entries.value.filter(row => row.id !== item.id); void dashboard() } catch (e) { error.value = e instanceof Error ? e.message : '删除失败' } } async function toggle(item: Entry) { try { const updated = await api(`/api/records/${item.kind}/${item.id}`, { method: 'PUT', body: JSON.stringify({ name: item.name, detail: item.detail, enabled: !item.enabled }) }); entries.value = entries.value.map(row => row.id === updated.id ? updated : row) } catch (e) { error.value = e instanceof Error ? e.message : '更新失败' } } -async function startAdapter(item: Entry) { try { await api(`/api/adapter/${item.id}/start`, { method: 'POST' }) } catch (e) { error.value = e instanceof Error ? e.message : '操作失败' } } +function adapterStateLabel(state?: AdapterStatus['state']) { return ({ stopped: '已停止', starting: '连接中', connected: '运行中', reconnecting: '重连中', stopping: '停止中', failed: '异常' } as const)[state ?? 'stopped'] } +async function startAdapter(item: Entry) { try { const state = adapterStatuses.value[item.id]?.state; await api(`/api/adapter/${item.id}/${state === 'connected' || state === 'reconnecting' || state === 'starting' ? 'stop' : 'start'}`, { method: 'POST' }); await loadAdapterStatuses() } catch (e) { error.value = e instanceof Error ? e.message : '操作失败'; await loadAdapterStatuses() } } +async function testAdapter(item: Entry) { error.value = ''; try { const result = await api<{ selfId?: string; nickname?: string }>(`/api/adapter/${item.id}/test`, { method: 'POST' }); toast.value = `连接成功${result.nickname ? `:${result.nickname}` : result.selfId ? `:${result.selfId}` : ''}`; setTimeout(() => toast.value = '', 2600) } catch (e) { error.value = e instanceof Error ? e.message : '连接测试失败' } }