From deab8571ba484f739522de397d53fcbf3b6b70d1 Mon Sep 17 00:00:00 2001 From: Minecraft0122 <168195378+Minecraft0122@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:31:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(adapter):=20=E6=94=AF=E6=8C=81=20QQ=20?= =?UTF-8?q?=E5=AE=98=E6=96=B9=20Bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + dev/README.md | 14 +- dev/internal/adapter/onebot11/manager.go | 94 ++++- dev/internal/adapter/onebot11/qq.go | 477 +++++++++++++++++++++++ dev/internal/adapter/onebot11/qq_test.go | 19 + dev/internal/web/web.go | 67 ++-- dev/internal/web/web_test.go | 8 + 7 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 dev/internal/adapter/onebot11/qq.go create mode 100644 dev/internal/adapter/onebot11/qq_test.go diff --git a/README.md b/README.md index 8aec585..594e400 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ EasybotCE 的主程序,使用 Go 提供管理 API、Vue 3 提供网页管理 | --- | --- | | 管理台、配置管理、登录认证、持久化运行日志 | 已实现 | | OneBot 11 正向 WebSocket 适配器 | 已实现连接、消息收发接口、状态管理与断线重连;已通过协议模拟器验证,真实账号仍待验收 | +| QQ 官方 Bot 适配器 | 已实现官方 Gateway 接收、QQBot token、群/C2C 消息收发和频道列表;真实账号仍待验收 | | MySQL、PostgreSQL 存储 | 已提供实现和集成测试;具体版本以对应 CI 验证结果为准 | | Minecraft 桥接、消息转发、账号绑定 | 尚未实现 | | JavaScript 插件执行、AI 对话、图片模板渲染 | 尚未实现;已有配置入口不代表运行能力可用 | diff --git a/dev/README.md b/dev/README.md index 0b67b95..607ce05 100644 --- a/dev/README.md +++ b/dev/README.md @@ -107,14 +107,22 @@ MySQL 连接格式为 `mysql://user:password@host:3306/database`;PostgreSQL 管理台和持久化 API 已覆盖适配器、服务器、玩家、账号、群组、允许与拒绝名单、命令定义、AI 服务配置、图片模板目录、JavaScript 插件目录和实例设置。配置数据通过统一记录表保存,启动时执行带版本的数据库迁移;存在配置入口不代表相应运行时已经实现。 -OneBot 11 是当前唯一可运行的适配器,使用正向 WebSocket,支持 Bearer 认证、身份握手、群聊及私聊事件归一化、消息发送和群列表调用、生命周期控制、状态及能力查询、有限退避重连。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。 +OneBot 11 和 QQ 官方 Bot 是当前可运行的适配器。OneBot 11 使用正向 WebSocket;QQ Bot 使用官方 Gateway,按 `AppID`/`AppSecret` 获取 `QQBot` token,并通过官方 OpenAPI 发送群/C2C 消息和读取频道列表。两者都支持身份握手、事件归一化、生命周期控制、状态及能力查询和有限退避重连。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。 + +QQ Bot 配置示例: + +```json +{"AppID":"你的机器人 AppID","AppSecret":"你的机器人 AppSecret","Intents":335546371} +``` + +`APIBase`、`GatewayURL` 和 `TokenURL` 仅用于沙箱或协议模拟测试,默认分别使用 QQ 官方 OpenAPI、Gateway 和 token 地址。QQ 官方 Gateway/WebSocket 链路存在逐步下线及 Webhook 灰度安排,生产使用前应以开放平台当前控制台能力为准;本实现暂不声称支持官方 Webhook 回调。 运行日志保留有界内存缓冲,并通过有界异步队列写入 `runtime_logs`。`/api/logs` 支持日志级别、组件、UTC 时间范围、关键词和分页筛选;`/api/logs/status` 提供保留策略和写入失败计数。日志页面可设置持久化开关、保留天数和最大条数,每小时清理一次。日志会遮蔽常见令牌、密码、数据库连接地址及验证码,默认不记录消息正文。 以下能力仍待完成或验证: -- **真实环境验证**:OneBot 11 已通过协议模拟器的认证、握手、事件接收、发送和重连测试;真实账号及平台限流行为尚未验证。 -- **尚未实现**:Milky、Telegram、OpenQQ 适配器运行时,Minecraft 桥接及配套插件,消息转发和账号绑定,JavaScript 插件执行、AI 对话、浏览器图片渲染、模板市场、PlaceholderAPI 与统计求值、平台安装器及 Docker 打包。 +- **真实环境验证**:OneBot 11 和 QQ Bot 已通过本地协议/API 模拟测试;真实账号、QQ 官方限流、权限和 Gateway 长连接稳定性尚未验证。 +- **尚未实现**:QQ 官方 Webhook 回调、Milky、Telegram、OpenQQ 适配器运行时,Minecraft 桥接及配套插件,消息转发和账号绑定,JavaScript 插件执行、AI 对话、浏览器图片渲染、模板市场、PlaceholderAPI 与统计求值、平台安装器及 Docker 打包。 - **参考边界**:原版 NativeAOT 可执行文件无法通过现有反编译资料可靠重建,独立的 EasyBot-Recovered 仓库仅作为管理 API、模块名称和行为的对照依据。 ## 测试与协作 diff --git a/dev/internal/adapter/onebot11/manager.go b/dev/internal/adapter/onebot11/manager.go index e8643bb..f36dc91 100644 --- a/dev/internal/adapter/onebot11/manager.go +++ b/dev/internal/adapter/onebot11/manager.go @@ -19,13 +19,21 @@ type Manager struct { cancel context.CancelFunc onEvent func(string, Event) mu sync.RWMutex - items map[string]*Runtime + items map[string]runtimeAdapter 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)} + return &Manager{store: db, ctx: ctx, cancel: cancel, onEvent: onEvent, items: make(map[string]runtimeAdapter), failures: make(map[string]Status)} +} + +type runtimeAdapter interface { + Start(context.Context) error + Stop(context.Context) error + Status() Status + Send(context.Context, string, string, string) (string, error) + Groups(context.Context) ([]map[string]any, error) } func (m *Manager) StartEnabled(ctx context.Context) error { @@ -43,7 +51,7 @@ func (m *Manager) StartEnabled(ctx context.Context) error { } continue } - if adapterType != "Onebot11" || !item.Enabled { + if (adapterType != "Onebot11" && adapterType != "QQBot") || !item.Enabled { continue } if err := m.Start(ctx, item.ID); err != nil { @@ -54,15 +62,26 @@ func (m *Manager) StartEnabled(ctx context.Context) error { } func (m *Manager) Test(ctx context.Context, id string) (map[string]any, error) { - _, config, err := m.loadConfig(ctx, id) + adapterType, raw, err := m.loadRawConfig(ctx, id) if err != nil { return nil, err } + if adapterType == "QQBot" { + config, parseErr := ParseQQConfig(raw) + if parseErr != nil { + return nil, parseErr + } + return TestQQConnection(ctx, config) + } + config, parseErr := ParseConfig(raw) + if parseErr != nil { + return nil, parseErr + } return TestConnection(ctx, config) } func (m *Manager) Start(ctx context.Context, id string) error { - item, config, err := m.loadConfig(ctx, id) + item, adapterType, raw, err := m.loadRawRecord(ctx, id) if err != nil { m.mu.Lock() m.failures[id] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)} @@ -72,6 +91,28 @@ func (m *Manager) Start(ctx context.Context, id string) error { if !item.Enabled { return fmt.Errorf("adapter is disabled") } + var runtime runtimeAdapter + if adapterType == "QQBot" { + config, parseErr := ParseQQConfig(raw) + if parseErr != nil { + return parseErr + } + runtime = NewQQRuntime(id, config, func(event Event) { + if m.onEvent != nil { + m.onEvent(id, event) + } + }) + } else { + config, parseErr := ParseConfig(raw) + if parseErr != nil { + return parseErr + } + runtime = NewRuntime(id, config, func(event Event) { + if m.onEvent != nil { + m.onEvent(id, event) + } + }) + } m.mu.Lock() if existing := m.items[id]; existing != nil { state := existing.Status().State @@ -80,11 +121,6 @@ func (m *Manager) Start(ctx context.Context, id string) error { 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() @@ -124,9 +160,13 @@ func (m *Manager) Status(id string) (Status, error) { } func (m *Manager) Capabilities(ctx context.Context, id string) ([]string, error) { - if _, _, err := m.loadConfig(ctx, id); err != nil { + adapterType, _, err := m.loadRawConfig(ctx, id) + if err != nil { return nil, err } + if adapterType == "QQBot" { + return []string{"group_message_receive", "private_message_receive", "group_message_send", "private_message_send", "group_list", "heartbeat"}, nil + } return append([]string(nil), Capabilities...), nil } @@ -149,7 +189,7 @@ func (m *Manager) Groups(ctx context.Context, id string) ([]map[string]any, erro func (m *Manager) Shutdown(ctx context.Context) { m.cancel() m.mu.RLock() - runtimes := make([]*Runtime, 0, len(m.items)) + runtimes := make([]runtimeAdapter, 0, len(m.items)) for _, runtime := range m.items { runtimes = append(runtimes, runtime) } @@ -165,28 +205,44 @@ func (m *Manager) RuntimeCount() int { return len(m.items) } -func (m *Manager) get(id string) *Runtime { +func (m *Manager) get(id string) runtimeAdapter { 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) + item, adapterType, raw, err := m.loadRawRecord(ctx, id) 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} + 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) loadRawConfig(ctx context.Context, id string) (string, json.RawMessage, error) { + _, adapterType, raw, err := m.loadRawRecord(ctx, id) + return adapterType, raw, err +} + +func (m *Manager) loadRawRecord(ctx context.Context, id string) (store.Record, string, json.RawMessage, error) { + item, err := m.getRecord(ctx, id) + if err != nil { + return store.Record{}, "", nil, err + } + adapterType, raw, err := parseRecord(item) + if err != nil { + return store.Record{}, "", nil, err + } + if adapterType != "Onebot11" && adapterType != "QQBot" { + return store.Record{}, "", nil, &APIError{Code: "adapter_type_unsupported", Message: "当前运行时不支持该适配器", Retryable: false} + } + return item, adapterType, raw, nil +} + func (m *Manager) getRecord(ctx context.Context, id string) (store.Record, error) { items, err := m.store.List(ctx, "adapters") if err != nil { diff --git a/dev/internal/adapter/onebot11/qq.go b/dev/internal/adapter/onebot11/qq.go new file mode 100644 index 0000000..0714c53 --- /dev/null +++ b/dev/internal/adapter/onebot11/qq.go @@ -0,0 +1,477 @@ +package onebot11 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + qqAPIBase = "https://api.sgroup.qq.com" + qqTokenURL = "https://bots.qq.com/app/getAppAccessToken" + qqGatewayPath = "/gateway" + qqDefaultIntents = (1 << 0) | (1 << 9) | (1 << 25) | (1 << 30) +) + +// QQConfig is the official QQ Bot credential and Gateway configuration. +type QQConfig struct { + AppID string `json:"AppID"` + AppSecret string `json:"AppSecret"` + Intents int `json:"Intents"` + APIBase string `json:"APIBase"` + GatewayURL string `json:"GatewayURL"` + TokenURL string `json:"TokenURL"` + Heartbeat int `json:"HeartbeatInterval"` +} + +func ParseQQConfig(raw json.RawMessage) (QQConfig, error) { + c := QQConfig{APIBase: qqAPIBase, TokenURL: qqTokenURL, Heartbeat: 30000, Intents: qqDefaultIntents} + if len(raw) != 0 && string(raw) != "null" && string(raw) != "{}" { + if err := json.Unmarshal(raw, &c); err != nil { + return QQConfig{}, &APIError{Code: "adapter_config_invalid", Message: "QQ Bot 配置不是有效 JSON"} + } + } + if c.APIBase == "" { + c.APIBase = qqAPIBase + } + if c.TokenURL == "" { + c.TokenURL = qqTokenURL + } + if c.Intents == 0 { + c.Intents = qqDefaultIntents + } + if c.AppID == "" || c.AppSecret == "" { + var aliases struct { + AppID string `json:"appId"` + AppSecret string `json:"appSecret"` + } + _ = json.Unmarshal(raw, &aliases) + if c.AppID == "" { + c.AppID = aliases.AppID + } + if c.AppSecret == "" { + c.AppSecret = aliases.AppSecret + } + } + if c.Heartbeat < 1000 || c.Heartbeat > 300000 { + c.Heartbeat = 30000 + } + if strings.TrimSpace(c.AppID) == "" || strings.TrimSpace(c.AppSecret) == "" { + return QQConfig{}, &APIError{Code: "adapter_config_invalid", Message: "QQ Bot 必须配置 AppID 和 AppSecret"} + } + return c, nil +} + +type qqToken struct { + Code int `json:"code"` + Message string `json:"message"` + AccessToken string `json:"access_token"` + ExpiresIn json.Number `json:"expires_in"` +} +type qqGateway struct { + URL string `json:"url"` +} + +type QQRuntime struct { + id string + config QQConfig + onEvent func(Event) + mu sync.RWMutex + status Status + conn *websocket.Conn + cancel context.CancelFunc + done chan struct{} + writeMu sync.Mutex + seq int64 + heartbeat int + token string + tokenExpiry time.Time +} + +func NewQQRuntime(id string, config QQConfig, onEvent func(Event)) *QQRuntime { + return &QQRuntime{id: id, config: config, onEvent: onEvent, status: Status{State: StateStopped}} +} + +func (r *QQRuntime) Status() Status { r.mu.RLock(); defer r.mu.RUnlock(); return r.status } + +func (r *QQRuntime) Start(parent context.Context) error { + r.mu.Lock() + if r.cancel != nil { + r.mu.Unlock() + return nil + } + ctx, cancel := context.WithCancel(parent) + r.cancel = cancel + r.done = make(chan struct{}) + r.status = Status{State: StateStarting, ConnectionID: newEcho()} + r.mu.Unlock() + go r.run(ctx) + return nil +} + +func (r *QQRuntime) Stop(ctx context.Context) error { + r.mu.RLock() + cancel, conn, done := r.cancel, r.conn, r.done + r.mu.RUnlock() + if cancel == nil { + return nil + } + cancel() + if conn != nil { + _ = conn.Close() + } + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *QQRuntime) run(ctx context.Context) { + defer func() { + r.mu.Lock() + r.cancel = nil + r.conn = nil + r.status.State = StateStopped + r.mu.Unlock() + close(r.done) + }() + backoff := time.Second + for ctx.Err() == nil { + if err := r.connect(ctx); err != nil { + r.fail(err) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + backoff = time.Second + err := r.readGateway(ctx) + if ctx.Err() != nil { + return + } + r.fail(err) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +func (r *QQRuntime) connect(ctx context.Context) error { + token, err := r.accessToken(ctx) + if err != nil { + return err + } + url := r.config.GatewayURL + if url == "" { + var g qqGateway + if err := r.request(ctx, http.MethodGet, r.config.APIBase+qqGatewayPath, nil, &g, token); err != nil { + return err + } + url = g.URL + } + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) + if err != nil { + return &APIError{Code: "adapter_network_error", Message: "无法连接 QQ Bot Gateway", Retryable: true} + } + r.mu.Lock() + r.conn = conn + r.status.State = StateStarting + r.mu.Unlock() + _, raw, err := conn.ReadMessage() + if err != nil { + conn.Close() + return &APIError{Code: "adapter_protocol_error", Message: "QQ Gateway Hello 读取失败", Retryable: true} + } + var hello struct { + Op int `json:"op"` + D struct { + HeartbeatInterval int `json:"heartbeat_interval"` + } `json:"d"` + } + if json.Unmarshal(raw, &hello) != nil || hello.Op != 10 { + conn.Close() + return &APIError{Code: "adapter_protocol_error", Message: "QQ Gateway Hello 无效", Retryable: true} + } + interval := hello.D.HeartbeatInterval + if interval < 1000 { + interval = r.config.Heartbeat + } + r.mu.Lock() + r.heartbeat = interval + r.mu.Unlock() + r.writeMu.Lock() + err = conn.WriteJSON(map[string]any{"op": 2, "d": map[string]any{"token": "QQBot " + token, "intents": r.config.Intents, "shard": []int{0, 1}, "properties": map[string]string{"$os": "easybot", "$browser": "easybot", "$device": "easybot"}}}) + r.writeMu.Unlock() + if err != nil { + conn.Close() + return err + } + r.mu.Lock() + r.status.LastError = "" + r.status.LastErrorCode = "" + r.mu.Unlock() + return nil +} + +func (r *QQRuntime) readGateway(ctx context.Context) error { + r.mu.RLock() + conn := r.conn + r.mu.RUnlock() + r.mu.RLock() + interval := r.heartbeat + r.mu.RUnlock() + if interval < 1000 { + interval = r.config.Heartbeat + } + ticker := time.NewTicker(time.Duration(interval) * time.Millisecond) + defer ticker.Stop() + result := make(chan error, 1) + go func() { + for { + _, raw, err := conn.ReadMessage() + if err != nil { + result <- err + return + } + var p struct { + Op int `json:"op"` + S int64 `json:"s"` + T string `json:"t"` + D json.RawMessage `json:"d"` + } + if json.Unmarshal(raw, &p) != nil { + continue + } + if p.S > 0 { + r.mu.Lock() + r.seq = p.S + r.mu.Unlock() + } + switch p.Op { + case 0: + r.dispatch(p.T, p.D) + case 7: + result <- fmt.Errorf("QQ Gateway 要求重连") + return + case 9: + result <- fmt.Errorf("QQ Gateway 会话无效") + return + } + } + }() + for { + select { + case <-ctx.Done(): + return nil + case <-result: + return &APIError{Code: "adapter_network_error", Message: "QQ Gateway 连接已断开", Retryable: true} + case <-ticker.C: + r.writeMu.Lock() + err := conn.WriteJSON(map[string]any{"op": 1, "d": r.seq}) + r.writeMu.Unlock() + if err != nil { + return &APIError{Code: "adapter_network_error", Message: "QQ Bot 心跳发送失败", Retryable: true} + } + } + } +} + +func (r *QQRuntime) dispatch(kind string, raw json.RawMessage) { + var d struct { + ID string `json:"id"` + Content string `json:"content"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id"` + GroupOpenID string `json:"group_openid"` + Author struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"author"` + OpenID string `json:"author_id"` + } + _ = json.Unmarshal(raw, &d) + now := time.Now().UTC() + e := Event{ID: d.ID, Type: kind, MessageID: d.ID, OccurredAt: now, Content: strings.TrimSpace(d.Content), Raw: append(json.RawMessage(nil), raw...)} + switch kind { + case "READY": + var ready struct { + SessionID string `json:"session_id"` + User struct{ ID, Username string } `json:"user"` + } + _ = json.Unmarshal(raw, &ready) + r.mu.Lock() + r.status.State = StateConnected + r.status.ConnectedAt = &now + r.status.SelfID = ready.User.ID + r.status.Nickname = ready.User.Username + r.status.ConnectionID = ready.SessionID + r.mu.Unlock() + return + case "GROUP_AT_MESSAGE_CREATE": + e.MessageType = "group" + e.PeerID = d.GroupOpenID + e.SenderID = d.OpenID + e.SenderName = d.Author.Username + case "C2C_MESSAGE_CREATE": + e.MessageType = "private" + e.PeerID = d.OpenID + e.SenderID = d.OpenID + e.SenderName = d.Author.Username + case "AT_MESSAGE_CREATE", "MESSAGE_CREATE": + e.MessageType = "group" + e.PeerID = d.ChannelID + e.SenderID = d.Author.ID + e.SenderName = d.Author.Username + default: + return + } + r.mu.Lock() + r.status.Received++ + r.status.LastReceivedAt = &now + r.mu.Unlock() + if e.ID == "" { + e.ID = newEcho() + } + if r.onEvent != nil { + r.onEvent(e) + } +} + +func (r *QQRuntime) Send(ctx context.Context, kind, peer, message string) (string, error) { + path := r.config.APIBase + body := map[string]any{"content": message} + if kind == "group" { + path += "/v2/groups/" + peer + "/messages" + } else if kind == "private" { + path += "/v2/users/" + peer + "/messages" + } else { + return "", &APIError{Code: "adapter_invalid_target", Message: "消息目标类型必须是 group 或 private"} + } + var out struct { + ID string `json:"id"` + } + if err := r.request(ctx, http.MethodPost, path, body, &out, ""); err != nil { + return "", err + } + r.mu.Lock() + r.status.Sent++ + r.mu.Unlock() + return out.ID, nil +} +func (r *QQRuntime) Groups(ctx context.Context) ([]map[string]any, error) { + var out []map[string]any + if err := r.request(ctx, http.MethodGet, r.config.APIBase+"/users/@me/guilds", nil, &out, ""); err != nil { + return nil, err + } + return out, nil +} + +func (r *QQRuntime) accessToken(ctx context.Context) (string, error) { + r.mu.RLock() + if r.token != "" && time.Now().Before(r.tokenExpiry) { + t := r.token + r.mu.RUnlock() + return t, nil + } + r.mu.RUnlock() + var out qqToken + if err := r.request(ctx, http.MethodPost, r.config.TokenURL, map[string]string{"appId": r.config.AppID, "clientSecret": r.config.AppSecret}, &out, ""); err != nil { + return "", err + } + if out.Code != 0 { + return "", &APIError{Code: "adapter_auth_failed", Message: "QQ Bot token 获取失败: " + out.Message} + } + if out.AccessToken == "" { + return "", &APIError{Code: "adapter_auth_failed", Message: "QQ Bot token 获取失败"} + } + seconds := int64(3600) + if out.ExpiresIn != "" { + if n, e := strconv.ParseInt(string(out.ExpiresIn), 10, 64); e == nil && n > 0 { + seconds = n + } + } + r.mu.Lock() + r.token = out.AccessToken + r.tokenExpiry = time.Now().Add(time.Duration(seconds-60) * time.Second) + r.mu.Unlock() + return out.AccessToken, nil +} +func (r *QQRuntime) request(ctx context.Context, method, url string, body any, out any, token string) error { + var reader io.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token == "" { + r.mu.RLock() + token = r.token + r.mu.RUnlock() + } + if token != "" { + req.Header.Set("Authorization", "QQBot "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return &APIError{Code: "adapter_network_error", Message: "QQ Bot API 请求失败", Retryable: true} + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{Code: "adapter_api_failed", Message: fmt.Sprintf("QQ Bot API 返回 HTTP %d", resp.StatusCode)} + } + if out != nil && len(data) > 0 && json.Unmarshal(data, out) != nil { + return &APIError{Code: "adapter_protocol_error", Message: "QQ Bot API 响应无效"} + } + return nil +} +func (r *QQRuntime) fail(err error) { + r.mu.Lock() + r.status.State = StateReconnecting + r.status.ReconnectCount++ + r.status.LastError = safeMessage(err) + r.status.LastErrorCode = errorCode(err) + r.mu.Unlock() +} + +func TestQQConnection(ctx context.Context, config QQConfig) (map[string]any, error) { + r := NewQQRuntime("test", config, nil) + token, err := r.accessToken(ctx) + if err != nil { + return nil, err + } + var g qqGateway + if config.GatewayURL != "" { + g.URL = config.GatewayURL + } else if err := r.request(ctx, http.MethodGet, config.APIBase+qqGatewayPath, nil, &g, token); err != nil { + return nil, err + } + return map[string]any{"connected": true, "authenticated": true, "gateway": g.URL, "intents": config.Intents}, nil +} diff --git a/dev/internal/adapter/onebot11/qq_test.go b/dev/internal/adapter/onebot11/qq_test.go new file mode 100644 index 0000000..7562ae6 --- /dev/null +++ b/dev/internal/adapter/onebot11/qq_test.go @@ -0,0 +1,19 @@ +package onebot11 + +import ( + "encoding/json" + "testing" +) + +func TestParseQQConfigDefaultsAndValidation(t *testing.T) { + c, err := ParseQQConfig(json.RawMessage(`{"AppID":"app","AppSecret":"secret"}`)) + if err != nil || c.APIBase != qqAPIBase || c.TokenURL != qqTokenURL || c.Intents == 0 || c.Heartbeat != 30000 { + t.Fatalf("unexpected QQ config: %#v, %v", c, err) + } + if _, err := ParseQQConfig(json.RawMessage(`{"AppID":"","AppSecret":"secret"}`)); err == nil { + t.Fatal("expected missing app id to be rejected") + } + if _, err := ParseQQConfig(json.RawMessage(`{"AppID":"app","AppSecret":"secret","HeartbeatInterval":10}`)); err != nil { + t.Fatalf("invalid low heartbeat should use safe default: %v", err) + } +} diff --git a/dev/internal/web/web.go b/dev/internal/web/web.go index 857e3b7..467909e 100644 --- a/dev/internal/web/web.go +++ b/dev/internal/web/web.go @@ -359,6 +359,7 @@ 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": "QQBot", "displayName": "QQ 官方 Bot", "platformName": "qqbot"}, }) } @@ -383,8 +384,8 @@ func (s *server) createAdapter(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &body) { return } - if body.AdapterType != "Onebot11" { - writeJSON(w, http.StatusNotImplemented, map[string]string{"message": "当前主程序仅支持 OneBot 11 适配器"}) + if body.AdapterType != "Onebot11" && body.AdapterType != "QQBot" { + writeJSON(w, http.StatusNotImplemented, map[string]string{"message": "当前主程序不支持该适配器"}) return } if body.DisplayName == "" { @@ -403,6 +404,9 @@ func (s *server) createAdapter(w http.ResponseWriter, r *http.Request) { writeAdapterError(w, err) return } + } else if _, err := onebot11.ParseQQConfig(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}) @@ -440,11 +444,19 @@ func (s *server) updateAdapter(w http.ResponseWriter, r *http.Request) { return } config = preserveAccessToken(oldDetail.Config, config) + if oldDetail.AdapterType == "QQBot" { + config = preserveSecret(oldDetail.Config, config, "AppSecret", "appSecretConfigured") + } if oldDetail.AdapterType == "Onebot11" { if _, err := onebot11.ParseConfig(config); err != nil { writeAdapterError(w, err) return } + } else if oldDetail.AdapterType == "QQBot" { + if _, err := onebot11.ParseQQConfig(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}) @@ -614,46 +626,57 @@ func publicAdapterRecord(item store.Record) store.Record { 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`) - } + redactSecret(config, "AccessToken", "accessTokenConfigured") + redactSecret(config, "AppSecret", "appSecretConfigured") publicConfig, _ := json.Marshal(config) publicDetail, _ := json.Marshal(map[string]any{"adapterType": detail.AdapterType, "config": json.RawMessage(publicConfig)}) item.Detail = string(publicDetail) return item } +func redactSecret(config map[string]json.RawMessage, canonical, configured string) { + legacy := strings.ToLower(canonical[:1]) + canonical[1:] + var secret string + _ = json.Unmarshal(config[canonical], &secret) + if secret == "" { + _ = json.Unmarshal(config[legacy], &secret) + } + if secret != "" { + config[canonical] = json.RawMessage(`""`) + delete(config, legacy) + config[configured] = json.RawMessage(`true`) + } +} + func preserveAccessToken(oldConfig, newConfig json.RawMessage) json.RawMessage { + return preserveSecret(oldConfig, newConfig, "AccessToken", "accessTokenConfigured") +} + +func preserveSecret(oldConfig, newConfig json.RawMessage, canonical, configuredKey string) json.RawMessage { var oldValues, newValues map[string]json.RawMessage if json.Unmarshal(oldConfig, &oldValues) != nil || json.Unmarshal(newConfig, &newValues) != nil { return newConfig } + legacy := strings.ToLower(canonical[:1]) + canonical[1:] var oldToken, newToken string - _ = json.Unmarshal(oldValues["AccessToken"], &oldToken) + _ = json.Unmarshal(oldValues[canonical], &oldToken) if oldToken == "" { - _ = json.Unmarshal(oldValues["accessToken"], &oldToken) + _ = json.Unmarshal(oldValues[legacy], &oldToken) } - _ = json.Unmarshal(newValues["AccessToken"], &newToken) + _ = json.Unmarshal(newValues[canonical], &newToken) if newToken == "" { - _ = json.Unmarshal(newValues["accessToken"], &newToken) + _ = json.Unmarshal(newValues[legacy], &newToken) } var configured bool - _ = json.Unmarshal(newValues["accessTokenConfigured"], &configured) + _ = json.Unmarshal(newValues[configuredKey], &configured) if oldToken != "" && (newToken == "" || newToken == "********" || configured) { - newValues["AccessToken"] = oldValues["AccessToken"] - if len(newValues["AccessToken"]) == 0 { - newValues["AccessToken"] = oldValues["accessToken"] + newValues[canonical] = oldValues[canonical] + if len(newValues[canonical]) == 0 { + newValues[canonical] = oldValues[legacy] } - delete(newValues, "accessToken") + delete(newValues, legacy) } - delete(newValues, "accessTokenConfigured") + delete(newValues, configuredKey) result, err := json.Marshal(newValues) if err != nil { return newConfig diff --git a/dev/internal/web/web_test.go b/dev/internal/web/web_test.go index 0ad3096..d8da6d7 100644 --- a/dev/internal/web/web_test.go +++ b/dev/internal/web/web_test.go @@ -69,6 +69,14 @@ func TestRecordCRUDAndSession(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":"QQBot","displayName":"QQ Official","configJson":"{\"AppID\":\"app\",\"AppSecret\":\"secret\"}"}`)) + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) + request.Header.Set("Content-Type", "application/json") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), `QQBot`) || strings.Contains(response.Body.String(), `secret`) { + t.Fatalf("QQ 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.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") From 3be2876090e10d633f169001a5d42f316508c1c3 Mon Sep 17 00:00:00 2001 From: Minecraft0122 <168195378+Minecraft0122@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:00:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(web):=20=E6=94=AF=E6=8C=81=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=A4=9A=E7=A7=8D=E9=80=82=E9=85=8D=E5=99=A8=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev/cmd/easybot/main.go | 4 ++-- dev/web/src/App.vue | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dev/cmd/easybot/main.go b/dev/cmd/easybot/main.go index 0c0fb3f..afec2f9 100644 --- a/dev/cmd/easybot/main.go +++ b/dev/cmd/easybot/main.go @@ -136,7 +136,7 @@ func run() (code int) { logger.Info(fmt.Sprintf("[AdapterManager] 发现 %d 个适配器配置", len(adapterConfigs))) } adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) { - logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")") + logs.Add("info", "adapter", "Adapter event received: "+event.Type+" (adapter "+id+")") }) if err := adapters.StartEnabled(ctx); err != nil { logger.Error("[AdapterManager] 适配器初始化失败", "error", err) @@ -152,7 +152,7 @@ func run() (code int) { } else if state.State == onebot11.StateFailed { logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 加载失败: %s", item.Name, runtime.Redact(state.LastError))) } else if state.State == onebot11.StateStopped { - logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 未加载,当前仅支持 OneBot 11", item.Name)) + logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 未加载,当前支持的适配器运行时均未建立连接", item.Name)) } else { logger.Info(fmt.Sprintf("[AdapterManager] 已加载适配器 %q,连接状态以运行时结果为准", item.Name)) } diff --git a/dev/web/src/App.vue b/dev/web/src/App.vue index 8b0d17d..b5efbec 100644 --- a/dev/web/src/App.vue +++ b/dev/web/src/App.vue @@ -28,7 +28,7 @@ const editing = ref(null), name = ref(''), detail = ref('{}'), ena 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: 'OneBot 11 正向 WebSocket 连接、运行诊断与消息 API。', servers: 'Minecraft 实例配置。桥接端连接能力仍在移植。', players: '玩家资料与社交账号绑定记录。', groups: '适配器发现的社交群组。', whitelist: '服务器准入名单。', blacklist: '封禁账号与备注。', commands: '自定义命令定义。命令执行引擎仍在移植。', settings: '实例级运行参数。', 'sync-rules': '群与服务器之间的消息路由规则。' } +const descriptions: Record = { adapters: 'OneBot 11 与 QQ 官方 Bot 连接、运行诊断与消息 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' }, @@ -52,6 +52,9 @@ onUnmounted(() => { if (logRefresh) window.clearInterval(logRefresh) }) function openDocs() { window.open('https://docs.inectar.cn/docs/easybot/intro', '_blank', 'noopener') } async function signOut() { try { await api('/api/auth/logout', { method: 'POST' }) } catch { /* 服务不可用时也清理页面状态。 */ } authenticated.value = false; authToken.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 adapterTemplate(type: string) { return type === 'QQBot' ? '{\n "AppID": "你的机器人 AppID",\n "AppSecret": "你的机器人 AppSecret"\n}' : '{\n "url": "ws://127.0.0.1:3001"\n}' } +function selectAdapterType(type: string) { adapterType.value = type; if (!editing.value) detail.value = adapterTemplate(type) } +function onAdapterTypeChange(event: Event) { selectAdapterType((event.target as HTMLSelectElement).value) } 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 }) }) @@ -134,7 +137,7 @@ async function testAdapter(item: Entry) { error.value = ''; try { const result = -
{{ editing ? '编辑' : '新建' }}配置

{{ editing ? '更新' : '添加' }}{{ title }}

+
{{ editing ? '编辑' : '新建' }}配置

{{ editing ? '更新' : '添加' }}{{ title }}

{{ toast }}