418 lines
13 KiB
Go
418 lines
13 KiB
Go
package qq_obv11
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
)
|
|
|
|
func TestAdapterNameAndConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{
|
|
Mode: "client",
|
|
RemoteURL: "ws://onebot.example:6700",
|
|
AccessToken: "secret",
|
|
MaxProactivePerWindow: 3,
|
|
ProactiveWindow: 5 * time.Second,
|
|
})
|
|
if a.Name() != platformName {
|
|
t.Errorf("Name() = %q, want %q", a.Name(), platformName)
|
|
}
|
|
cfg := a.Config()
|
|
if cfg["mode"] != "client" {
|
|
t.Errorf("Config() mode = %v, want client", cfg["mode"])
|
|
}
|
|
if cfg["remoteURL"] != "ws://onebot.example:6700" {
|
|
t.Errorf("Config() remoteURL = %v", cfg["remoteURL"])
|
|
}
|
|
if cfg["accessToken"] != "secret" {
|
|
t.Errorf("Config() accessToken = %v", cfg["accessToken"])
|
|
}
|
|
if cfg["maxProactivePerWindow"] != 3 {
|
|
t.Errorf("Config() maxProactivePerWindow = %v, want 3", cfg["maxProactivePerWindow"])
|
|
}
|
|
if got := cfg["proactiveWindowSeconds"].(float64); got != 5 {
|
|
t.Errorf("Config() proactiveWindowSeconds = %v, want 5", got)
|
|
}
|
|
}
|
|
|
|
func TestAdapterDefaultModeIsServer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{})
|
|
if a.Config()["mode"] != "server" {
|
|
t.Errorf("default mode = %v, want server", a.Config()["mode"])
|
|
}
|
|
}
|
|
|
|
func TestAdapterCapabilities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
caps := NewAdapter(Config{}).Capabilities().Normalize()
|
|
if caps.MaxMessageLength != defaultMaxMessageLength {
|
|
t.Errorf("MaxMessageLength = %d, want %d", caps.MaxMessageLength, defaultMaxMessageLength)
|
|
}
|
|
if caps.RecommendBurstMax != defaultRecommendBurstMax {
|
|
t.Errorf("RecommendBurstMax = %d, want %d", caps.RecommendBurstMax, defaultRecommendBurstMax)
|
|
}
|
|
if !caps.Markdown || !caps.Image || !caps.Voice || !caps.Proactive {
|
|
t.Errorf("expected markdown/image/voice/proactive on, got %+v", caps)
|
|
}
|
|
if caps.Reaction || caps.TypingHint {
|
|
t.Errorf("expected reaction/typing off, got %+v", caps)
|
|
}
|
|
}
|
|
|
|
func TestAdapterSetMessageHandlerAndDispatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{})
|
|
got := make(chan adapter.UnifiedMessage, 1)
|
|
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) {
|
|
got <- m
|
|
})
|
|
|
|
want := adapter.UnifiedMessage{Platform: platformName, ChannelType: adapter.ChannelDirect, ContentType: adapter.ContentText, Content: "hi"}
|
|
a.dispatch(context.Background(), want)
|
|
|
|
select {
|
|
case m := <-got:
|
|
if m.Content != "hi" {
|
|
t.Errorf("handler got content = %q, want hi", m.Content)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("handler not invoked")
|
|
}
|
|
}
|
|
|
|
func TestAdapterHealthCheckDisconnected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{Mode: "server", ListenAddr: "127.0.0.1:0"})
|
|
if a.IsConnected() {
|
|
t.Error("IsConnected() = true before Connect, want false")
|
|
}
|
|
if err := a.HealthCheck(context.Background()); !errors.Is(err, adapter.ErrNotConnected) {
|
|
t.Errorf("HealthCheck() error = %v, want ErrNotConnected", err)
|
|
}
|
|
}
|
|
|
|
func TestAdapterSendWhenDisconnected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{})
|
|
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
|
|
Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelDirect, ChannelID: "100", Content: "x"}},
|
|
})
|
|
if !errors.Is(err, adapter.ErrNotConnected) {
|
|
t.Errorf("SendMessage() error = %v, want ErrNotConnected", err)
|
|
}
|
|
}
|
|
|
|
func TestAdapterDisconnectIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{})
|
|
ctx := context.Background()
|
|
if err := a.Disconnect(ctx); err != nil {
|
|
t.Fatalf("first Disconnect error: %v", err)
|
|
}
|
|
if err := a.Disconnect(ctx); err != nil {
|
|
t.Fatalf("second Disconnect error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAdapterServerModeRoundTrip spins up the adapter as a server, connects a
|
|
// fake OneBot client, and verifies the message event is normalized and that a
|
|
// reply is emitted as a send_msg frame back to the client.
|
|
func TestAdapterServerModeRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := NewAdapter(Config{Mode: "server", ListenAddr: "127.0.0.1:0"})
|
|
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")
|
|
}
|
|
if a.IsConnected() {
|
|
t.Error("server mode should not be connected before a client connects")
|
|
}
|
|
|
|
msgCh := make(chan adapter.UnifiedMessage, 1)
|
|
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) { msgCh <- m })
|
|
|
|
client, err := dialWS(context.Background(), nil, "ws://"+addr+"/", "")
|
|
if err != nil {
|
|
t.Fatalf("client dial error: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if !waitUntil(3*time.Second, a.IsConnected) {
|
|
t.Fatal("server never reported connected after client dialed")
|
|
}
|
|
|
|
evt := `{"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"}}`
|
|
if err := client.WriteMessage(opText, []byte(evt)); err != nil {
|
|
t.Fatalf("client write error: %v", err)
|
|
}
|
|
|
|
select {
|
|
case m := <-msgCh:
|
|
if m.Platform != platformName || m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" {
|
|
t.Errorf("unexpected message: platform=%q type=%q group=%q", m.Platform, m.ChannelType, m.GroupID)
|
|
}
|
|
if m.GroupName != "Team" || m.Content != "hello" {
|
|
t.Errorf("group message = %+v, want Team/hello", m)
|
|
}
|
|
if m.SenderName != "Bobby" || m.Sender.Role != adapter.RoleMember {
|
|
t.Errorf("sender = %+v, want Bobby/member", m.Sender)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("server handler never received a message")
|
|
}
|
|
|
|
// Reply.
|
|
reply := adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{
|
|
{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"},
|
|
}}
|
|
if err := a.SendMessage(context.Background(), reply); err != nil {
|
|
t.Fatalf("SendMessage error: %v", err)
|
|
}
|
|
|
|
seen := make(chan []byte, 1)
|
|
go func() {
|
|
_, payload, err := client.ReadMessage()
|
|
if err != nil {
|
|
seen <- nil
|
|
return
|
|
}
|
|
seen <- payload
|
|
}()
|
|
select {
|
|
case payload := <-seen:
|
|
if payload == nil {
|
|
t.Fatal("client never received a send_msg")
|
|
}
|
|
assertSendMsg(t, payload)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("client never received a send_msg frame")
|
|
}
|
|
}
|
|
|
|
// TestAdapterClientModeReceiveAndSend spins up a fake OneBot server that pushes
|
|
// a group event, then verifies the adapter (client) normalizes it and replies.
|
|
func TestAdapterClientModeReceiveAndSend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gotCh := make(chan []byte, 4)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgradeWS(w, r)
|
|
if err != nil {
|
|
t.Errorf("server upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
evt := `{"post_type":"message","message_type":"group","time":1700000001,"self_id":100,"message_id":10,"user_id":42,"group_id":123,"message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":42,"nickname":"Bob","role":"member"}}`
|
|
_ = conn.WriteMessage(opText, []byte(evt))
|
|
for {
|
|
_, payload, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
gotCh <- payload
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
|
a := NewAdapter(Config{Mode: "client", RemoteURL: url})
|
|
msgCh := make(chan adapter.UnifiedMessage, 1)
|
|
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) { msgCh <- m })
|
|
|
|
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("client mode IsConnected() = false after Connect, want true")
|
|
}
|
|
if err := a.HealthCheck(context.Background()); err != nil {
|
|
t.Errorf("HealthCheck() error = %v, want nil", err)
|
|
}
|
|
|
|
select {
|
|
case m := <-msgCh:
|
|
if m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" || m.Content != "hi" {
|
|
t.Errorf("received message = %+v, want group/123/hi", m)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("adapter never received the pushed event")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
select {
|
|
case payload := <-gotCh:
|
|
assertSendMsg(t, payload)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("fake OneBot server never received a send_msg")
|
|
}
|
|
}
|
|
|
|
// TestSendProactiveRateLimited verifies proactive sends are rejected after the
|
|
// configured per-window quota is exceeded.
|
|
func TestSendProactiveRateLimited(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgradeWS(w, r)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
|
a := NewAdapter(Config{
|
|
Mode: "client",
|
|
RemoteURL: url,
|
|
MaxProactivePerWindow: 2,
|
|
ProactiveWindow: 10 * time.Second,
|
|
})
|
|
if err := a.Connect(context.Background()); err != nil {
|
|
t.Fatalf("Connect error: %v", err)
|
|
}
|
|
defer func() { _ = a.Disconnect(context.Background()) }()
|
|
|
|
msg := adapter.UnifiedMessage{ChannelType: adapter.ChannelDirect, ChannelID: "100", Content: "x"}
|
|
if err := a.SendProactive(context.Background(), msg); err != nil {
|
|
t.Fatalf("first SendProactive error: %v", err)
|
|
}
|
|
if err := a.SendProactive(context.Background(), msg); err != nil {
|
|
t.Fatalf("second SendProactive error: %v", err)
|
|
}
|
|
if err := a.SendProactive(context.Background(), msg); !errors.Is(err, ErrRateLimited) {
|
|
t.Errorf("third SendProactive error = %v, want ErrRateLimited", err)
|
|
}
|
|
}
|
|
|
|
// TestReadMessagesPullMode verifies the pull-based ReadMessages sink receives
|
|
// normalized messages when no push handler is installed.
|
|
func TestReadMessagesPullMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgradeWS(w, r)
|
|
if err != nil {
|
|
t.Errorf("server upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
evt := `{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":3,"user_id":200,"message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":200,"nickname":"Alice","role":""}}`
|
|
_ = conn.WriteMessage(opText, []byte(evt))
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
|
|
a := NewAdapter(Config{Mode: "client", RemoteURL: url})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
emitCh := make(chan adapter.UnifiedMessage, 1)
|
|
go a.ReadMessages(ctx, func(m adapter.UnifiedMessage) { emitCh <- m })
|
|
// Wait until the sink is registered before connecting, so the pushed event
|
|
// is not dropped before ReadMessages installs its emit callback.
|
|
if !waitUntil(3*time.Second, func() bool {
|
|
a.emitMu.Lock()
|
|
defer a.emitMu.Unlock()
|
|
return a.emit != nil
|
|
}) {
|
|
t.Fatal("ReadMessages never registered its emit sink")
|
|
}
|
|
|
|
if err := a.Connect(context.Background()); err != nil {
|
|
t.Fatalf("Connect error: %v", err)
|
|
}
|
|
defer func() { _ = a.Disconnect(context.Background()) }()
|
|
|
|
select {
|
|
case m := <-emitCh:
|
|
if m.ChannelType != adapter.ChannelDirect || m.ChannelID != "200" || m.Content != "hi" {
|
|
t.Errorf("pull-mode message = %+v, want direct/200/hi", m)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("ReadMessages never received the pushed event")
|
|
}
|
|
}
|
|
|
|
func assertSendMsg(t *testing.T, payload []byte) {
|
|
t.Helper()
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
Params struct {
|
|
MessageType string `json:"message_type"`
|
|
UserID int64 `json:"user_id"`
|
|
GroupID int64 `json:"group_id"`
|
|
Message []map[string]any `json:"message"`
|
|
} `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
t.Fatalf("send_msg unmarshal error: %v (payload=%s)", err, payload)
|
|
}
|
|
if req.Action != "send_msg" {
|
|
t.Errorf("action = %q, want send_msg", req.Action)
|
|
}
|
|
if req.Params.MessageType != "group" {
|
|
t.Errorf("message_type = %q, want group", req.Params.MessageType)
|
|
}
|
|
if req.Params.GroupID != 123 {
|
|
t.Errorf("group_id = %d, want 123", req.Params.GroupID)
|
|
}
|
|
if len(req.Params.Message) != 1 {
|
|
t.Fatalf("message segments len = %d, want 1", len(req.Params.Message))
|
|
}
|
|
first := req.Params.Message[0]
|
|
if first["type"] != "text" || first["data"].(map[string]any)["text"] != "pong" {
|
|
t.Errorf("first segment = %+v, want text pong", first)
|
|
}
|
|
}
|
|
|
|
func waitUntil(timeout time.Duration, cond func() bool) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return true
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|