feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口)
This commit is contained in:
1 parent
7831b0ea1b
commit
88dc12466d
13 files changed
+2627
No files matched your search
@@ -0,0 +1,279 @@
|
||||
package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// msgCollector collects emitted messages under a mutex.
|
||||
type msgCollector struct {
|
||||
mu sync.Mutex
|
||||
msgs []adapter.UnifiedMessage
|
||||
}
|
||||
|
||||
func (c *msgCollector) add(m adapter.UnifiedMessage) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.msgs = append(c.msgs, m)
|
||||
}
|
||||
|
||||
func (c *msgCollector) snapshot() []adapter.UnifiedMessage {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]adapter.UnifiedMessage(nil), c.msgs...)
|
||||
}
|
||||
|
||||
func (c *msgCollector) count() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.msgs)
|
||||
}
|
||||
|
||||
func textItem(t string) map[string]any {
|
||||
return map[string]any{"type": 1, "text_item": map[string]any{"text": t}}
|
||||
}
|
||||
|
||||
func TestReadMessages_LongPoll_CursorAndContextToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
msg1, msg2 := longPollMessages()
|
||||
|
||||
updatesCalls := 0
|
||||
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
||||
if req.path != "/ilink/bot/getupdates" {
|
||||
return handlerResult{status: 404}
|
||||
}
|
||||
updatesCalls++
|
||||
if updatesCalls == 1 {
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"msgs": []any{msg1, msg2}, "get_updates_buf": "cursor-1",
|
||||
})}
|
||||
}
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"msgs": []any{}, "get_updates_buf": "cursor-2",
|
||||
})}
|
||||
})
|
||||
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
|
||||
|
||||
col := &msgCollector{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ad.ReadMessages(ctx, col.add)
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return col.count() >= 2 })
|
||||
// Ensure the second long poll has run so the cursor echo is observable.
|
||||
waitFor(t, func() bool { return len(getupdatesReqs(rec)) >= 2 })
|
||||
cancel()
|
||||
waitDone(t, done)
|
||||
|
||||
assertDecodedMessages(t, col.snapshot())
|
||||
assertCursorAndContext(t, ad, rec)
|
||||
}
|
||||
|
||||
func longPollMessages() (json.RawMessage, json.RawMessage) {
|
||||
msg1 := inlineJSON(map[string]any{
|
||||
"client_id": "m-1", "from_user_id": "user-1", "to_user_id": "bot-1",
|
||||
"message_type": 1, "message_state": 2, "context_token": "tok-a",
|
||||
"item_list": []any{textItem("hi")},
|
||||
})
|
||||
msg2 := inlineJSON(map[string]any{
|
||||
"client_id": "m-2", "from_user_id": "user-2", "to_user_id": "bot-1",
|
||||
"message_type": 1, "message_state": 2, "context_token": "tok-b",
|
||||
"item_list": []any{map[string]any{"type": 2, "image_item": map[string]any{"url": "https://cdn/x.jpg"}}},
|
||||
})
|
||||
return msg1, msg2
|
||||
}
|
||||
|
||||
func assertDecodedMessages(t *testing.T, msgs []adapter.UnifiedMessage) {
|
||||
t.Helper()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("emitted %d messages, want 2", len(msgs))
|
||||
}
|
||||
m := msgs[0]
|
||||
if m.Platform != "weixin-ilink" || m.SenderID != "user-1" || m.ChannelID != "user-1" {
|
||||
t.Fatalf("msg0 identity = platform %q sender %q channel %q", m.Platform, m.SenderID, m.ChannelID)
|
||||
}
|
||||
if m.ChannelType != adapter.ChannelDirect || m.ContentType != adapter.ContentText || m.Content != "hi" {
|
||||
t.Fatalf("msg0 = channel %q content %q type %q", m.ChannelType, m.Content, m.ContentType)
|
||||
}
|
||||
m2 := msgs[1]
|
||||
if m2.ContentType != adapter.ContentImage || len(m2.Media) != 1 {
|
||||
t.Fatalf("msg1 = type %q media %d", m2.ContentType, len(m2.Media))
|
||||
}
|
||||
if m2.Media[0].URL != "https://cdn/x.jpg" || m2.Media[0].Kind != adapter.MediaImage {
|
||||
t.Fatalf("msg1 media = %+v", m2.Media[0])
|
||||
}
|
||||
if m2.Timestamp.IsZero() {
|
||||
t.Fatal("msg1 Timestamp is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func assertCursorAndContext(t *testing.T, ad *Adapter, rec *reqRecorder) {
|
||||
t.Helper()
|
||||
if got := ad.contextFor("user-1"); got != "tok-a" {
|
||||
t.Fatalf("contextFor(user-1) = %q, want tok-a", got)
|
||||
}
|
||||
if got := ad.contextFor("user-2"); got != "tok-b" {
|
||||
t.Fatalf("contextFor(user-2) = %q, want tok-b", got)
|
||||
}
|
||||
ups := getupdatesReqs(rec)
|
||||
if len(ups) < 2 {
|
||||
t.Fatalf("getupdates requests = %d, want >=2", len(ups))
|
||||
}
|
||||
body2 := ups[1].bodyJSON()
|
||||
if body2 == nil {
|
||||
t.Fatal("second getupdates body is not JSON")
|
||||
}
|
||||
if got := body2["get_updates_buf"]; got != "cursor-1" {
|
||||
t.Fatalf("second poll get_updates_buf = %v, want cursor-1", got)
|
||||
}
|
||||
if ups[0].auth != "Bearer tok" {
|
||||
t.Fatalf("poll Authorization = %q, want Bearer tok", ups[0].auth)
|
||||
}
|
||||
}
|
||||
|
||||
// waitDone waits for the read loop goroutine to exit or fails after a timeout.
|
||||
func waitDone(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ReadMessages did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessages_SkipsNonUserMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := onceUpdates(t, rec, jsonBody(map[string]any{
|
||||
"msgs": []any{
|
||||
inlineJSON(map[string]any{"message_type": 1, "from_user_id": "u-1", "item_list": []any{textItem("yo")}}),
|
||||
inlineJSON(map[string]any{"message_type": 2, "from_user_id": "bot-1", "item_list": []any{textItem("echo")}}),
|
||||
},
|
||||
"get_updates_buf": "cursor-1",
|
||||
}))
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
|
||||
|
||||
col := &msgCollector{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ad.ReadMessages(ctx, col.add)
|
||||
}()
|
||||
waitFor(t, func() bool { return col.count() >= 1 })
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if got := col.count(); got != 1 {
|
||||
t.Fatalf("emitted %d messages, want 1 (bot echo skipped)", got)
|
||||
}
|
||||
if msgs := col.snapshot(); msgs[0].Content != "yo" {
|
||||
t.Fatalf("content = %q, want yo", msgs[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessages_GroupMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := onceUpdates(t, nil, jsonBody(map[string]any{
|
||||
"msgs": []any{inlineJSON(map[string]any{
|
||||
"message_type": 1, "from_user_id": "u-1", "group_id": "g-1",
|
||||
"context_token": "tok-g", "item_list": []any{textItem("group msg")},
|
||||
})},
|
||||
"get_updates_buf": "c1",
|
||||
}))
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
|
||||
|
||||
col := &msgCollector{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ad.ReadMessages(ctx, col.add)
|
||||
}()
|
||||
waitFor(t, func() bool { return col.count() >= 1 })
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
m := col.snapshot()[0]
|
||||
if m.ChannelType != adapter.ChannelGroup || m.GroupID != "g-1" || m.ChannelID != "g-1" {
|
||||
t.Fatalf("group message = channel %q group %q channelid %q", m.ChannelType, m.GroupID, m.ChannelID)
|
||||
}
|
||||
if !m.Sender.IsGroup || m.Sender.GroupID != "g-1" {
|
||||
t.Fatalf("sender group flags = %+v", m.Sender)
|
||||
}
|
||||
if m.IsGroup() != true {
|
||||
t.Fatal("IsGroup() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessages_SessionExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||
if req.path != "/ilink/bot/getupdates" {
|
||||
return handlerResult{status: 404}
|
||||
}
|
||||
return handlerResult{body: jsonBody(map[string]any{"errcode": -14})}
|
||||
})
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ad.ReadMessages(ctx, func(m adapter.UnifiedMessage) {})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("ReadMessages did not stop on session expired")
|
||||
}
|
||||
cancel()
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after session expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessages_HandlerDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := onceUpdates(t, nil, jsonBody(map[string]any{
|
||||
"msgs": []any{inlineJSON(map[string]any{
|
||||
"message_type": 1, "from_user_id": "u-1", "context_token": "tok-h",
|
||||
"item_list": []any{textItem("hello")},
|
||||
})},
|
||||
"get_updates_buf": "c1",
|
||||
}))
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
|
||||
|
||||
handlerCalls := &msgCollector{}
|
||||
ad.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { handlerCalls.add(m) })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// emit is intentionally nil: the handler must receive the message.
|
||||
ad.ReadMessages(ctx, nil)
|
||||
}()
|
||||
waitFor(t, func() bool { return handlerCalls.count() >= 1 })
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if got := handlerCalls.count(); got != 1 {
|
||||
t.Fatalf("handler called %d times, want 1", got)
|
||||
}
|
||||
if m := handlerCalls.snapshot()[0]; m.Content != "hello" {
|
||||
t.Fatalf("handler content = %q, want hello", m.Content)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user