- 四端=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 请裁决)
447 lines
15 KiB
Go
447 lines
15 KiB
Go
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)
|
||
}
|
||
}
|