feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 四端=ws×client / ws×server(原有,补语义注释)+ **http×client**(新增 http_client.go:
  调 OneBot HTTP API、请求-响应、retcode 判成败、token 只走 Authorization 头)+
  **http×server**(新增 http_server.go:监听 listen_addr 收上报、fail-closed 鉴权、10MiB 体上限)
- 配置面:适配器新增 transport / api_base_url / report_path(json tag 沿用既有口径);
  env 兜底 CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH};**默认值=现状**(ws+server)
- 出站分流:session.go 发送路径按 transport 走 WS 写帧或 HTTP POST(不改它则 HTTP 端无法出站)
- 端口豁免边界:http+server 沿用 AGENTS §9 已登记的同一豁免(design/13 §0 原文即含 HTTP server),
  且 listen_addr 为空时报错、**绝不开端口**(与 ws 侧同名校验成对)
- 关键取舍(已上报):OneBot 的 HTTP 是「动作 + 上报」两半,mode 决定**哪一半必需**、
  另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」,四端仍各自可独立选择与测试
- 复核:build/vet 绿 + qq_obv11/cmd 包测试绿 + `-race` 干净;10 条新用例(HTTP 两端全部用本地
  httptest 假 OneBot,未连真实 QQ);变异 3 条(伪造 retcode 成功 / 去掉上报鉴权门 / 默认值翻 http)已还原
- 未接:get_group_list(后续)、无 token 时放行(与既有 ws_server 一致,如需 fail-closed 请裁决)
This commit is contained in:
AskaEth committed 2026-10-04 13:58:18 +08:00
1 parent 0e1f163ef9
commit 9253fef92a
9 files changed
+972 -45

No files matched your search

@@ -0,0 +1,446 @@
package qq_obv11
// 本文件覆盖裁决 1.4 的**四个端**(http/ws × client/server)里的 HTTP 两端,以及
// transport/mode 配置项的默认值与归一化。WS 两端已有既有用例(TestAdapterServerModeRoundTrip /
// TestAdapterClientModeReceiveAndSend),这里用本地 httptest 假 OneBot 实现驱动,绝不连真实 QQ。
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// groupEventJSON 是一条标准的 OneBot v11 群消息上报。
const groupEventJSON = `{"post_type":"message","message_type":"group","time":1700000000,` +
`"self_id":100,"message_id":9,"user_id":42,"group_id":123,"group_name":"Team",` +
`"message":[{"type":"text","data":{"text":"hello"}}],` +
`"sender":{"user_id":42,"nickname":"Bob","card":"Bobby","role":"member"}}`
// recordedAPIRequest 是假 OneBot HTTP API 收到的一条 action 请求。
type recordedAPIRequest struct {
action string
auth string
body []byte
}
// fakeOneBotAPI 是本地假的 OneBot HTTP API:记录收到的 action 请求,并按 retCode /
// status 回一个标准响应信封,用来验证 HTTP client 端的「请求-响应 + 判成败」语义。
type fakeOneBotAPI struct {
mu sync.Mutex
requests []recordedAPIRequest
retCode int
status string
}
func (f *fakeOneBotAPI) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.requests = append(f.requests, recordedAPIRequest{
action: strings.TrimPrefix(r.URL.Path, "/"),
auth: r.Header.Get("Authorization"),
body: body,
})
retCode, status := f.retCode, f.status
f.mu.Unlock()
if status == "" {
status = "ok"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": status,
"retcode": retCode,
"data": map[string]any{},
"msg": "fake",
})
})
}
func (f *fakeOneBotAPI) snapshot() []recordedAPIRequest {
f.mu.Lock()
defer f.mu.Unlock()
return append([]recordedAPIRequest(nil), f.requests...)
}
// reportResult 是一次上报 POST 的结果。
type reportResult struct {
status int
body string
}
// postReport 模拟 OneBot 实现向 Cyrene 的上报端 POST 一条事件。
func postReport(t *testing.T, url, token, body string) reportResult {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatalf("build report request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post report to %s: %v", url, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return reportResult{status: resp.StatusCode, body: string(raw)}
}
// TestHTTPClientModeSendsViaAPI 覆盖 **http + client**:Cyrene→OneBot(调 OneBot 的
// HTTP API)。断言 send_msg 真的发出去、出站 token 只在 Authorization 头里、该模式
// 不开任何监听端口,并且探活走的是 get_login_info。
func TestHTTPClientModeSendsViaAPI(t *testing.T) {
t.Parallel()
api := &fakeOneBotAPI{}
srv := httptest.NewServer(api.handler())
defer srv.Close()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeClient,
APIBaseURL: srv.URL,
AccessToken: "secret-token",
})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
if !a.IsConnected() {
t.Error("IsConnected() = false after Connect, want true")
}
if addr := a.Addr(); addr != "" {
t.Errorf("http client mode must not listen, Addr() = %q", addr)
}
if err := a.HealthCheck(context.Background()); err != nil {
t.Errorf("HealthCheck() error = %v, want nil", err)
}
if err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}},
}); err != nil {
t.Fatalf("SendMessage error: %v", err)
}
reqs := api.snapshot()
if len(reqs) != 2 {
t.Fatalf("api requests = %+v, want health probe + send_msg", reqs)
}
if reqs[0].action != "get_login_info" || reqs[1].action != "send_msg" {
t.Errorf("actions = %q,%q want get_login_info,send_msg", reqs[0].action, reqs[1].action)
}
for _, r := range reqs {
if r.auth != "Bearer secret-token" {
t.Errorf("%s Authorization = %q, want Bearer header", r.action, r.auth)
}
if strings.Contains(string(r.body), "access_token") {
t.Errorf("%s body carries access_token: %s", r.action, r.body)
}
}
assertSendMsg(t, reqs[1].body)
}
// TestHTTPServerModeReceivesReport 覆盖 **http + server**:OneBot→Cyrene(OneBot 把
// 事件上报 POST 到 Cyrene 的监听端口)。断言事件被归一化派发、成功回 204;只收不发
// 的端没有出站通道,SendMessage 必须明确报未连接而不是静默成功。
func TestHTTPServerModeReceivesReport(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
AccessToken: "secret-token",
ReportPath: "/onebot/report",
})
got := make(chan adapter.UnifiedMessage, 1)
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
addr := a.Addr()
if addr == "" {
t.Fatal("Addr() empty after Connect, want the report listener")
}
if res := postReport(t, "http://"+addr+"/onebot/report", "secret-token", groupEventJSON); res.status != http.StatusNoContent {
t.Fatalf("report status = %d (body %q), want 204", res.status, res.body)
}
select {
case m := <-got:
if m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" || m.Content != "hello" {
t.Errorf("report message = %+v, want group/123/hello", m)
}
case <-time.After(3 * time.Second):
t.Fatal("http report never reached the message handler")
}
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}},
})
if !errors.Is(err, adapter.ErrNotConnected) {
t.Errorf("SendMessage error = %v, want ErrNotConnected (no api_base_url)", err)
}
}
// TestHTTPServerModeRejectsBadToken 验证上报端 fail-closed:配置了 access_token 时,
// 缺失或错误的 token 一律 403,事件绝不进消息流。
func TestHTTPServerModeRejectsBadToken(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
AccessToken: "secret-token",
})
// 缓冲足够大:即便鉴权门失效、两条上报都进来,handler 也不会阻塞在发送上。
got := make(chan adapter.UnifiedMessage, 4)
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
url := "http://" + a.Addr() + "/"
for _, token := range []string{"", "wrong-token"} {
if res := postReport(t, url, token, groupEventJSON); res.status != http.StatusForbidden {
t.Errorf("token %q status = %d, want 403", token, res.status)
}
}
select {
case m := <-got:
t.Fatalf("rejected report still reached the handler: %+v", m)
case <-time.After(150 * time.Millisecond):
}
}
// TestHTTPBothEndsFullLoop 覆盖 HTTP 双端闭环:同一个适配器既收 OneBot 上报,又能经
// OneBot HTTP API 回消息——OneBot v11 的 HTTP 通信本来就是这两半合起来才闭环。
func TestHTTPBothEndsFullLoop(t *testing.T) {
t.Parallel()
api := &fakeOneBotAPI{}
srv := httptest.NewServer(api.handler())
defer srv.Close()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
APIBaseURL: srv.URL,
})
got := make(chan adapter.UnifiedMessage, 1)
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
if res := postReport(t, "http://"+a.Addr()+"/", "", groupEventJSON); res.status != http.StatusNoContent {
t.Fatalf("report status = %d, want 204", res.status)
}
select {
case <-got:
case <-time.After(3 * time.Second):
t.Fatal("report never reached the message handler")
}
if err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}},
}); err != nil {
t.Fatalf("SendMessage error: %v", err)
}
reqs := api.snapshot()
if len(reqs) != 1 || reqs[0].action != "send_msg" {
t.Fatalf("api requests = %+v, want exactly one send_msg", reqs)
}
assertSendMsg(t, reqs[0].body)
}
// TestHTTPClientSurfacesAPIFailure 验证 HTTP client 端等响应并判成败:OneBot 回
// retcode!=0 时发送必须报错。v1「写完就报成功」的实现在这里会静默失败,这正是本次
// 改造要修的反例。
func TestHTTPClientSurfacesAPIFailure(t *testing.T) {
t.Parallel()
api := &fakeOneBotAPI{retCode: 100, status: "failed"}
srv := httptest.NewServer(api.handler())
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelDirect, ChannelID: "42", Content: "hi"}},
})
if err == nil {
t.Fatal("SendMessage returned nil, want the OneBot retcode failure surfaced")
}
if !strings.Contains(err.Error(), "retcode=100") {
t.Errorf("error = %v, want it to name retcode=100", err)
}
}
// TestHTTPModeRequiresItsOwnEndpoint 验证 mode 决定哪一端必需:配置不全时 Connect
// 失败,且**绝不留半开的监听端口**(桥接器对 Connect 失败不会回头调用 Disconnect)。
func TestHTTPModeRequiresItsOwnEndpoint(t *testing.T) {
t.Parallel()
// http+client 缺 api_base_url。
client := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient})
if err := client.Connect(context.Background()); err == nil {
t.Fatal("http client mode without apiBaseUrl: Connect() = nil, want error")
}
_ = client.Disconnect(context.Background())
// http+server 缺 listen_addr:默认绝不监听。
server := NewAdapter(Config{Transport: transportHTTP})
if err := server.Connect(context.Background()); err == nil {
t.Fatal("http server mode without listenAddr: Connect() = nil, want error")
}
if addr := server.Addr(); addr != "" {
t.Errorf("failed Connect must not leave a listener, Addr() = %q", addr)
}
_ = server.Disconnect(context.Background())
// 出站基址非法时先失败、后绑端口:端口不能漏。
leak := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
APIBaseURL: "ws://not-http.example:3000",
})
if err := leak.Connect(context.Background()); err == nil {
t.Fatal("invalid apiBaseUrl: Connect() = nil, want error")
}
if addr := leak.Addr(); addr != "" {
t.Errorf("invalid apiBaseUrl leaked a listener at %q", addr)
}
_ = leak.Disconnect(context.Background())
// server 端给齐 listen_addr、不给 api_base_url 时仍可启动(只收入站)。
recvOnly := NewAdapter(Config{Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0"})
if err := recvOnly.Connect(context.Background()); err != nil {
t.Fatalf("http server mode with listenAddr: Connect error = %v", err)
}
if recvOnly.Addr() == "" {
t.Error("http server mode: Addr() empty, want the report listener")
}
_ = recvOnly.Disconnect(context.Background())
}
// TestFourEndsConfigDefaultsAndNormalization 验证四端配置项的默认值口径(裁决 1.4):
// 不给任何新配置时 transport=ws、mode=server,与改造前完全一致;未知值也不静默改道。
func TestFourEndsConfigDefaultsAndNormalization(t *testing.T) {
t.Parallel()
base := NewAdapter(Config{}).Config()
if base["transport"] != transportWS || base["mode"] != modeServer {
t.Errorf("defaults = transport:%v mode:%v, want ws/server", base["transport"], base["mode"])
}
if base["reportPath"] != defaultReportPath {
t.Errorf("default reportPath = %v, want %q", base["reportPath"], defaultReportPath)
}
cases := []struct{ in, want string }{
{"", transportWS},
{"WS", transportWS},
{" http ", transportHTTP},
{"tcp", transportWS},
}
for _, c := range cases {
if got := NewAdapter(Config{Transport: c.in}).Config()["transport"]; got != c.want {
t.Errorf("transport %q → %v, want %q", c.in, got, c.want)
}
}
// http+client 的 Config() 必须把 api_base_url 作为远端地址上报,否则 channelbridge
// 的凭据门(connect.go:143-147 按 remoteURL 判 client 模式)会把该账号当「无凭据」
// 跳过,HTTP client 端在生产组合根里永远不会连接。
cfg := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeClient,
APIBaseURL: "http://127.0.0.1:3000",
}).Config()
if cfg["remoteURL"] != "http://127.0.0.1:3000" {
t.Errorf("http client remoteURL = %v, want the api base url", cfg["remoteURL"])
}
}
// TestNewOBHTTPClientValidatesBaseURL 验证出站基址显式校验:必须 http/https 且带 host,
// 不得内嵌凭据(凭据只走 Authorization 头,避免进 URL / 日志 / 错误信息)。
func TestNewOBHTTPClientValidatesBaseURL(t *testing.T) {
t.Parallel()
for _, bad := range []string{"", "ws://host:6700", "ftp://host", "http://", "http://user:pass@host:3000"} {
if _, err := newOBHTTPClient(bad, "tok"); err == nil {
t.Errorf("newOBHTTPClient(%q) = nil error, want rejection", bad)
}
}
c, err := newOBHTTPClient("http://127.0.0.1:3000/", "tok")
if err != nil {
t.Fatalf("newOBHTTPClient valid base error: %v", err)
}
if c.baseURL != "http://127.0.0.1:3000" {
t.Errorf("baseURL = %q, want the trailing slash trimmed", c.baseURL)
}
}
// TestHTTPReportPathAndMethodGuards 验证上报路径归一化、非 POST 拒绝,以及未声明的
// 路径不会被上报端受理。
func TestHTTPReportPathAndMethodGuards(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
ReportPath: "hook",
})
if got := a.Config()["reportPath"]; got != "/hook" {
t.Fatalf("reportPath = %v, want /hook", got)
}
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
getResp, err := http.Get("http://" + a.Addr() + "/hook")
if err != nil {
t.Fatalf("GET /hook: %v", err)
}
defer func() { _ = getResp.Body.Close() }()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
other, err := http.Post("http://"+a.Addr()+"/elsewhere", "application/json", strings.NewReader(groupEventJSON))
if err != nil {
t.Fatalf("POST /elsewhere: %v", err)
}
defer func() { _ = other.Body.Close() }()
if other.StatusCode != http.StatusNotFound {
t.Errorf("POST /elsewhere status = %d, want 404", other.StatusCode)
}
}