- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动) - 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等 - 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误 - 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
230 lines
7.3 KiB
Go
230 lines
7.3 KiB
Go
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 对 sendmessage 回以 ret:0,并记录每个请求。
|
|
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})
|
|
// 推进到 24 小时窗口之外。
|
|
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)
|
|
}
|
|
|
|
// 已上传的字节(PUT 请求体)必须能解密回原始媒体。
|
|
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 返回第一个 sendmessage 请求的解码后请求体。
|
|
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
|
|
}
|