Files
AskaEth 9253fef92a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
- 四端=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 请裁决)
2026-10-04 13:58:18 +08:00

447 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}