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,229 @@
|
||||
package weixin_ilink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// sendOKServer replies ret:0 to sendmessage, recording every request.
|
||||
func sendOKServer(t *testing.T, rec *reqRecorder) *httptest.Server {
|
||||
t.Helper()
|
||||
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
||||
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
|
||||
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
|
||||
}
|
||||
return handlerResult{status: 404}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMessage_RequiresConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
|
||||
})
|
||||
if !errors.Is(err, adapter.ErrNotConnected) {
|
||||
t.Fatalf("err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_RequiresContextToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := sendOKServer(t, rec)
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
|
||||
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
|
||||
})
|
||||
if !errors.Is(err, ErrNoContextToken) {
|
||||
t.Fatalf("err = %v, want ErrNoContextToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_SendsTextReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := sendOKServer(t, rec)
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
|
||||
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
|
||||
|
||||
resp := adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "pong"}},
|
||||
ReplyTo: "anything",
|
||||
}
|
||||
if err := ad.SendMessage(context.Background(), resp); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
body := getSendBody(t, rec)
|
||||
msg := body["msg"].(map[string]any)
|
||||
if msg["to_user_id"] != "u-1" {
|
||||
t.Fatalf("to_user_id = %v, want u-1", msg["to_user_id"])
|
||||
}
|
||||
if msg["context_token"] != "tok-1" {
|
||||
t.Fatalf("context_token = %v, want tok-1", msg["context_token"])
|
||||
}
|
||||
item := msg["item_list"].([]any)[0].(map[string]any)
|
||||
if typ, ok := item["type"].(float64); !ok || typ != 1 {
|
||||
t.Fatalf("item type = %v, want 1", item["type"])
|
||||
}
|
||||
if txt := item["text_item"].(map[string]any)["text"]; txt != "pong" {
|
||||
t.Fatalf("text = %v, want pong", txt)
|
||||
}
|
||||
if msg["client_id"] == "" || msg["client_id"] == nil {
|
||||
t.Fatal("client_id missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendProactive_WithinWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := sendOKServer(t, rec)
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
|
||||
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||
ad.clock = func() time.Time { return now }
|
||||
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
|
||||
|
||||
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
|
||||
SenderID: "u-1", Content: "daily tip",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendProactive within window: %v", err)
|
||||
}
|
||||
body := getSendBody(t, rec)
|
||||
msg := body["msg"].(map[string]any)
|
||||
if msg["to_user_id"] != "u-1" || msg["context_token"] != "tok-p" {
|
||||
t.Fatalf("proactive send = to_user_id %v context %v", msg["to_user_id"], msg["context_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendProactive_ExpiredWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
srv := sendOKServer(t, rec)
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
|
||||
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||
ad.clock = func() time.Time { return now }
|
||||
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
|
||||
// Advance beyond the 24h window.
|
||||
now = now.Add(25 * time.Hour)
|
||||
|
||||
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
|
||||
SenderID: "u-1", Content: "too late",
|
||||
})
|
||||
if !errors.Is(err, ErrProactiveWindow) {
|
||||
t.Fatalf("err = %v, want ErrProactiveWindow", err)
|
||||
}
|
||||
if len(getupdatesReqs(rec)) != 0 {
|
||||
t.Fatal("expired window must not send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendProactive_NoPriorInbound(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := sendOKServer(t, &reqRecorder{})
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
|
||||
SenderID: "u-1", Content: "hello",
|
||||
})
|
||||
if !errors.Is(err, ErrProactiveWindow) {
|
||||
t.Fatalf("err = %v, want ErrProactiveWindow (no prior inbound)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_OutboundMedia(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
var srv *httptest.Server
|
||||
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
||||
switch {
|
||||
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
|
||||
return handlerResult{body: jsonBody(map[string]any{"upload_url": srv.URL + "/upload/1"})}
|
||||
case req.method == http.MethodPut && req.path == "/upload/1":
|
||||
return handlerResult{status: 200, body: []byte("ok")}
|
||||
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
|
||||
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
|
||||
default:
|
||||
return handlerResult{status: 404}
|
||||
}
|
||||
})
|
||||
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
|
||||
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
|
||||
|
||||
imageData := []byte("fake-jpeg-bytes")
|
||||
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{
|
||||
SenderID: "u-1",
|
||||
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: imageData, Name: "a.jpg"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage media: %v", err)
|
||||
}
|
||||
|
||||
body := getSendBody(t, rec)
|
||||
msg := body["msg"].(map[string]any)
|
||||
item := msg["item_list"].([]any)[0].(map[string]any)
|
||||
if typ, ok := item["type"].(float64); !ok || typ != 2 {
|
||||
t.Fatalf("media item type = %v, want 2", item["type"])
|
||||
}
|
||||
if item["cdn_ref"] != srv.URL+"/upload/1" {
|
||||
t.Fatalf("cdn_ref = %v, want upload url", item["cdn_ref"])
|
||||
}
|
||||
aesKeyEncoded, _ := item["aes_key"].(string)
|
||||
if aesKeyEncoded == "" {
|
||||
t.Fatal("aes_key missing on media item")
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(aesKeyEncoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode aes_key: %v", err)
|
||||
}
|
||||
if len(key) != aes.BlockSize {
|
||||
t.Fatalf("aes_key = %d bytes, want %d", len(key), aes.BlockSize)
|
||||
}
|
||||
|
||||
// The uploaded bytes (PUT body) must decrypt back to the original media.
|
||||
puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut })
|
||||
if len(puts) != 1 {
|
||||
t.Fatalf("PUT requests = %d, want 1", len(puts))
|
||||
}
|
||||
decrypted, err := aesECBDecrypt(key, puts[0].body)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt uploaded media: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, imageData) {
|
||||
t.Fatalf("uploaded media mismatch: got %q want %q", decrypted, imageData)
|
||||
}
|
||||
}
|
||||
|
||||
// getSendBody returns the decoded body of the first sendmessage request.
|
||||
func getSendBody(t *testing.T, rec *reqRecorder) map[string]any {
|
||||
t.Helper()
|
||||
reqs := rec.find(func(q recordedReq) bool {
|
||||
return q.method == http.MethodPost && q.path == "/ilink/bot/sendmessage"
|
||||
})
|
||||
if len(reqs) == 0 {
|
||||
t.Fatal("no sendmessage request recorded")
|
||||
}
|
||||
body := reqs[0].bodyJSON()
|
||||
if body == nil {
|
||||
t.Fatal("sendmessage body is not JSON")
|
||||
}
|
||||
return body
|
||||
}
|
||||
Reference in new issue
Block a user