初始化仓库
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterPlatform("qq", New)
|
||||
}
|
||||
|
||||
// Platform connects to a OneBot v11 implementation (NapCat, LLOneBot, etc.)
|
||||
// via forward WebSocket. It receives message events and sends messages back
|
||||
// through the same WS connection.
|
||||
type Platform struct {
|
||||
wsURL string // e.g. "ws://127.0.0.1:3001"
|
||||
token string // optional access_token
|
||||
allowFrom string // comma-separated user IDs or "*"
|
||||
shareSessionInChannel bool
|
||||
handler core.MessageHandler
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
echoSeq atomic.Int64
|
||||
echoCh sync.Map // echo -> chan json.RawMessage
|
||||
cancel context.CancelFunc
|
||||
selfID int64
|
||||
dedup core.MessageDedup
|
||||
groupNameCache sync.Map // groupID -> group name
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Platform, error) {
|
||||
wsURL, _ := opts["ws_url"].(string)
|
||||
if wsURL == "" {
|
||||
wsURL = "ws://127.0.0.1:3001"
|
||||
}
|
||||
token, _ := opts["token"].(string)
|
||||
allowFrom, _ := opts["allow_from"].(string)
|
||||
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
|
||||
|
||||
core.CheckAllowFrom("qq", allowFrom)
|
||||
return &Platform{
|
||||
wsURL: wsURL,
|
||||
token: token,
|
||||
allowFrom: allowFrom,
|
||||
shareSessionInChannel: shareSessionInChannel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Platform) Name() string { return "qq" }
|
||||
|
||||
func (p *Platform) Start(handler core.MessageHandler) error {
|
||||
p.handler = handler
|
||||
|
||||
header := http.Header{}
|
||||
if p.token != "" {
|
||||
header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq: ws connect failed (%s): %w", p.wsURL, err)
|
||||
}
|
||||
p.conn = conn
|
||||
|
||||
slog.Info("qq: connected to OneBot", "url", p.wsURL)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancel = cancel
|
||||
|
||||
// Start readLoop BEFORE callAPI: callAPI's response is routed by readLoop,
|
||||
// so calling it first would always time out after 15s and leave selfID=0,
|
||||
// which disables the self-message filter in handleMessage and lets the bot
|
||||
// respond to its own messages.
|
||||
go p.readLoop(ctx)
|
||||
|
||||
// Get bot self info
|
||||
if info, err := p.callAPI("get_login_info", nil); err == nil {
|
||||
if uid, ok := info["user_id"].(float64); ok {
|
||||
p.selfID = int64(uid)
|
||||
}
|
||||
nick, _ := info["nickname"].(string)
|
||||
slog.Info("qq: logged in", "qq", p.selfID, "nickname", nick)
|
||||
} else {
|
||||
slog.Warn("qq: get_login_info failed; self-message filter disabled until next reconnect", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) readLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_, raw, err := p.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Error("qq: ws read error, reconnecting...", "error", err)
|
||||
p.reconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If this is an API response (has "echo" field), route to caller
|
||||
if echo, ok := payload["echo"].(string); ok {
|
||||
if ch, loaded := p.echoCh.LoadAndDelete(echo); loaded {
|
||||
if dataCh, ok := ch.(chan json.RawMessage); ok {
|
||||
dataCh <- raw
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise it's an event
|
||||
postType, _ := payload["post_type"].(string)
|
||||
if postType == "message" {
|
||||
p.handleMessage(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) reconnect() {
|
||||
for i := 1; i <= 30; i++ {
|
||||
time.Sleep(time.Duration(i) * 2 * time.Second)
|
||||
header := http.Header{}
|
||||
if p.token != "" {
|
||||
header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(p.wsURL, header)
|
||||
if err != nil {
|
||||
slog.Warn("qq: reconnect attempt failed", "attempt", i, "error", err)
|
||||
continue
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.conn = conn
|
||||
p.mu.Unlock()
|
||||
slog.Info("qq: reconnected")
|
||||
return
|
||||
}
|
||||
slog.Error("qq: failed to reconnect after 30 attempts")
|
||||
}
|
||||
|
||||
func (p *Platform) handleMessage(payload map[string]any) {
|
||||
msgType, _ := payload["message_type"].(string)
|
||||
userID := jsonInt64(payload, "user_id")
|
||||
groupID := jsonInt64(payload, "group_id")
|
||||
messageID := jsonInt64(payload, "message_id")
|
||||
|
||||
if userID == p.selfID {
|
||||
return
|
||||
}
|
||||
|
||||
if ts, ok := payload["time"].(float64); ok && ts > 0 {
|
||||
if core.IsOldMessage(time.Unix(int64(ts), 0)) {
|
||||
slog.Debug("qq: ignoring old message after restart", "time", int64(ts))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msgIDStr := strconv.FormatInt(messageID, 10)
|
||||
if p.dedup.IsDuplicate(msgIDStr) {
|
||||
slog.Debug("qq: duplicate message ignored", "message_id", messageID)
|
||||
return
|
||||
}
|
||||
|
||||
if !p.isAllowed(userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract sender info
|
||||
var userName string
|
||||
if sender, ok := payload["sender"].(map[string]any); ok {
|
||||
card, _ := sender["card"].(string)
|
||||
nick, _ := sender["nickname"].(string)
|
||||
if card != "" {
|
||||
userName = card
|
||||
} else {
|
||||
userName = nick
|
||||
}
|
||||
}
|
||||
|
||||
// Parse message content from CQ message array or raw_message
|
||||
text, images, audio := p.parseMessage(payload)
|
||||
if text == "" && len(images) == 0 && audio == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var sessionKey string
|
||||
if msgType == "group" {
|
||||
if p.shareSessionInChannel {
|
||||
sessionKey = fmt.Sprintf("qq:g:%d", groupID)
|
||||
} else {
|
||||
sessionKey = fmt.Sprintf("qq:%d:%d", groupID, userID)
|
||||
}
|
||||
} else {
|
||||
sessionKey = fmt.Sprintf("qq:%d", userID)
|
||||
}
|
||||
|
||||
rctx := &replyContext{
|
||||
messageType: msgType,
|
||||
userID: userID,
|
||||
groupID: groupID,
|
||||
messageID: int32(messageID),
|
||||
}
|
||||
|
||||
var chatName string
|
||||
if msgType == "group" {
|
||||
chatName = p.resolveGroupName(groupID)
|
||||
}
|
||||
|
||||
msg := &core.Message{
|
||||
SessionKey: sessionKey,
|
||||
Platform: "qq",
|
||||
MessageID: strconv.FormatInt(messageID, 10),
|
||||
UserID: strconv.FormatInt(userID, 10),
|
||||
UserName: userName,
|
||||
ChatName: chatName,
|
||||
Content: text,
|
||||
Images: images,
|
||||
Audio: audio,
|
||||
ReplyCtx: rctx,
|
||||
}
|
||||
|
||||
slog.Debug("qq: message received", "type", msgType, "user", userID, "text_len", len(text))
|
||||
p.handler(p, msg)
|
||||
}
|
||||
|
||||
func (p *Platform) parseMessage(payload map[string]any) (string, []core.ImageAttachment, *core.AudioAttachment) {
|
||||
var textParts []string
|
||||
var images []core.ImageAttachment
|
||||
var audio *core.AudioAttachment
|
||||
|
||||
// OneBot message can be array of segments or a string
|
||||
switch msg := payload["message"].(type) {
|
||||
case []any:
|
||||
for _, seg := range msg {
|
||||
s, ok := seg.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
segType, _ := s["type"].(string)
|
||||
data, _ := s["data"].(map[string]any)
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch segType {
|
||||
case "text":
|
||||
if text, ok := data["text"].(string); ok {
|
||||
textParts = append(textParts, text)
|
||||
}
|
||||
case "image":
|
||||
if url, ok := data["url"].(string); ok && url != "" {
|
||||
imgData, mime, err := downloadFile(url)
|
||||
if err != nil {
|
||||
slog.Warn("qq: download image failed", "error", err)
|
||||
continue
|
||||
}
|
||||
images = append(images, core.ImageAttachment{
|
||||
MimeType: mime,
|
||||
Data: imgData,
|
||||
})
|
||||
}
|
||||
case "record":
|
||||
if url, ok := data["url"].(string); ok && url != "" {
|
||||
audioData, _, err := downloadFile(url)
|
||||
if err != nil {
|
||||
slog.Warn("qq: download audio failed", "error", err)
|
||||
continue
|
||||
}
|
||||
format := "silk"
|
||||
if f, ok := data["file"].(string); ok {
|
||||
if strings.HasSuffix(f, ".amr") {
|
||||
format = "amr"
|
||||
} else if strings.HasSuffix(f, ".mp3") {
|
||||
format = "mp3"
|
||||
}
|
||||
}
|
||||
audio = &core.AudioAttachment{
|
||||
Data: audioData,
|
||||
Format: format,
|
||||
}
|
||||
}
|
||||
case "at":
|
||||
// Ignore @mentions in parsed text
|
||||
}
|
||||
}
|
||||
default:
|
||||
// raw_message fallback (string with CQ codes)
|
||||
if raw, ok := payload["raw_message"].(string); ok {
|
||||
textParts = append(textParts, stripCQCodes(raw))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(textParts, "")), images, audio
|
||||
}
|
||||
|
||||
// Reply sends a message as a reply to an incoming message.
|
||||
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
|
||||
return p.Send(ctx, replyCtx, content)
|
||||
}
|
||||
|
||||
// Send sends a message to the conversation identified by replyCtx.
|
||||
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
|
||||
rctx, ok := replyCtx.(*replyContext)
|
||||
if !ok {
|
||||
return fmt.Errorf("qq: invalid reply context")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"message": content,
|
||||
}
|
||||
|
||||
if rctx.messageType == "group" {
|
||||
params["group_id"] = rctx.groupID
|
||||
_, err := p.callAPI("send_group_msg", params)
|
||||
return err
|
||||
}
|
||||
|
||||
params["user_id"] = rctx.userID
|
||||
_, err := p.callAPI("send_private_msg", params)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendImage sends an image to the conversation.
|
||||
// Implements core.ImageSender.
|
||||
func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
|
||||
rctx, ok := replyCtx.(*replyContext)
|
||||
if !ok {
|
||||
return fmt.Errorf("qq: SendImage: invalid reply context type %T", replyCtx)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(img.Data)
|
||||
segments := []map[string]any{
|
||||
{"type": "image", "data": map[string]any{"file": "base64://" + b64}},
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"message": segments,
|
||||
}
|
||||
|
||||
if rctx.messageType == "group" {
|
||||
params["group_id"] = rctx.groupID
|
||||
_, err := p.callAPI("send_group_msg", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq: send image: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
params["user_id"] = rctx.userID
|
||||
_, err := p.callAPI("send_private_msg", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq: send image: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ core.ImageSender = (*Platform)(nil)
|
||||
|
||||
func (p *Platform) Stop() error {
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
if p.conn != nil {
|
||||
return p.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) resolveGroupName(groupID int64) string {
|
||||
if groupID == 0 {
|
||||
return ""
|
||||
}
|
||||
fallback := strconv.FormatInt(groupID, 10)
|
||||
if cached, ok := p.groupNameCache.Load(fallback); ok {
|
||||
return cached.(string)
|
||||
}
|
||||
result, err := p.callAPI("get_group_info", map[string]any{"group_id": groupID})
|
||||
if err != nil {
|
||||
slog.Debug("qq: resolve group name failed", "group_id", groupID, "error", err)
|
||||
return fallback
|
||||
}
|
||||
name, _ := result["group_name"].(string)
|
||||
if name != "" {
|
||||
p.groupNameCache.Store(fallback, name)
|
||||
return name
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ── OneBot API call via WebSocket ───────────────────────────────
|
||||
|
||||
func (p *Platform) callAPI(action string, params map[string]any) (map[string]any, error) {
|
||||
seq := p.echoSeq.Add(1)
|
||||
echo := strconv.FormatInt(seq, 10)
|
||||
|
||||
req := map[string]any{
|
||||
"action": action,
|
||||
"echo": echo,
|
||||
}
|
||||
if params != nil {
|
||||
req["params"] = params
|
||||
}
|
||||
|
||||
ch := make(chan json.RawMessage, 1)
|
||||
p.echoCh.Store(echo, ch)
|
||||
defer p.echoCh.Delete(echo)
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
err = p.conn.WriteMessage(websocket.TextMessage, data)
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq: ws write: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case raw := <-ch:
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
RetCode int `json:"retcode"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &resp) != nil {
|
||||
return nil, fmt.Errorf("qq: invalid API response")
|
||||
}
|
||||
if resp.RetCode != 0 {
|
||||
return nil, fmt.Errorf("qq: API %s failed (retcode=%d)", action, resp.RetCode)
|
||||
}
|
||||
var result map[string]any
|
||||
_ = json.Unmarshal(resp.Data, &result)
|
||||
return result, nil
|
||||
|
||||
case <-time.After(15 * time.Second):
|
||||
return nil, fmt.Errorf("qq: API %s timeout", action)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
type replyContext struct {
|
||||
messageType string // "private" or "group"
|
||||
userID int64
|
||||
groupID int64
|
||||
messageID int32
|
||||
}
|
||||
|
||||
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
||||
// qq:{userID}, qq:{groupID}:{userID} or qq:g:{groupID}
|
||||
parts := strings.SplitN(sessionKey, ":", 3)
|
||||
if len(parts) < 2 || parts[0] != "qq" {
|
||||
return nil, fmt.Errorf("qq: invalid session key %q", sessionKey)
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
if parts[1] == "g" {
|
||||
gid, _ := strconv.ParseInt(parts[2], 10, 64)
|
||||
return &replyContext{messageType: "group", groupID: gid}, nil
|
||||
}
|
||||
gid, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
uid, _ := strconv.ParseInt(parts[2], 10, 64)
|
||||
return &replyContext{messageType: "group", groupID: gid, userID: uid}, nil
|
||||
}
|
||||
uid, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
return &replyContext{messageType: "private", userID: uid}, nil
|
||||
}
|
||||
|
||||
func (p *Platform) isAllowed(userID int64) bool {
|
||||
if p.allowFrom == "" || p.allowFrom == "*" {
|
||||
return true
|
||||
}
|
||||
uid := strconv.FormatInt(userID, 10)
|
||||
for _, allowed := range strings.Split(p.allowFrom, ",") {
|
||||
if strings.TrimSpace(allowed) == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonInt64(m map[string]any, key string) int64 {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func stripCQCodes(s string) string {
|
||||
var result strings.Builder
|
||||
for len(s) > 0 {
|
||||
idx := strings.Index(s, "[CQ:")
|
||||
if idx < 0 {
|
||||
result.WriteString(s)
|
||||
break
|
||||
}
|
||||
result.WriteString(s[:idx])
|
||||
end := strings.Index(s[idx:], "]")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
s = s[idx+end+1:]
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func downloadFile(url string) ([]byte, string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
mime := resp.Header.Get("Content-Type")
|
||||
if mime == "" {
|
||||
mime = http.DetectContentType(data)
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestPlatform_Name(t *testing.T) {
|
||||
p := &Platform{}
|
||||
if got := p.Name(); got != "qq" {
|
||||
t.Errorf("Name() = %q, want %q", got, "qq")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultWSURL(t *testing.T) {
|
||||
p, err := New(map[string]any{})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
platform := p.(*Platform)
|
||||
if platform.wsURL != "ws://127.0.0.1:3001" {
|
||||
t.Errorf("wsURL = %q, want %q", platform.wsURL, "ws://127.0.0.1:3001")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_CustomWSURL(t *testing.T) {
|
||||
p, err := New(map[string]any{
|
||||
"ws_url": "ws://example.com:8080",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
platform := p.(*Platform)
|
||||
if platform.wsURL != "ws://example.com:8080" {
|
||||
t.Errorf("wsURL = %q, want %q", platform.wsURL, "ws://example.com:8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_WithToken(t *testing.T) {
|
||||
p, err := New(map[string]any{
|
||||
"token": "my-secret-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
platform := p.(*Platform)
|
||||
if platform.token != "my-secret-token" {
|
||||
t.Errorf("token = %q, want %q", platform.token, "my-secret-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_WithAllowFrom(t *testing.T) {
|
||||
p, err := New(map[string]any{
|
||||
"allow_from": "user1,user2,*",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
platform := p.(*Platform)
|
||||
if platform.allowFrom != "user1,user2,*" {
|
||||
t.Errorf("allowFrom = %q, want %q", platform.allowFrom, "user1,user2,*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ShareSessionInChannel(t *testing.T) {
|
||||
p, err := New(map[string]any{
|
||||
"share_session_in_channel": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
platform := p.(*Platform)
|
||||
if !platform.shareSessionInChannel {
|
||||
t.Error("shareSessionInChannel = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// verify Platform implements core.Platform
|
||||
var _ core.Platform = (*Platform)(nil)
|
||||
|
||||
// TestStart_FetchesSelfIDWithoutTimeout verifies that Start() completes
|
||||
// promptly with selfID populated from the get_login_info OneBot API call.
|
||||
// Regression for a bug where Start invoked callAPI BEFORE launching readLoop,
|
||||
// so the API response had no consumer and callAPI always timed out after 15s
|
||||
// — leaving selfID=0 and disabling the self-message filter in handleMessage.
|
||||
func TestStart_FetchesSelfIDWithoutTimeout(t *testing.T) {
|
||||
const botUserID = 999999
|
||||
|
||||
upgrader := websocket.Upgrader{}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
for {
|
||||
_, msg, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(msg, &req); err != nil {
|
||||
continue
|
||||
}
|
||||
if req["action"] == "get_login_info" {
|
||||
echo, _ := req["echo"].(string)
|
||||
resp := map[string]any{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"echo": echo,
|
||||
"data": map[string]any{"user_id": botUserID, "nickname": "TestBot"},
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
_ = c.WriteMessage(websocket.TextMessage, raw)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &Platform{
|
||||
wsURL: "ws" + strings.TrimPrefix(ts.URL, "http"),
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- p.Start(func(core.Platform, *core.Message) {})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = p.Stop()
|
||||
t.Fatal("Start did not complete within 5s; readLoop likely starts after callAPI, so get_login_info never gets a response")
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
if p.selfID != botUserID {
|
||||
t.Errorf("selfID = %d, want %d (self-message filter would be disabled)", p.selfID, botUserID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user