按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐: ① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param; ③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key, 图片优先 image_item.aeskey)。 配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。 (逐项测试、变异红行与诚实边界见该切片报告)
236 lines
7.7 KiB
Go
236 lines
7.7 KiB
Go
package weixin_ilink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// TestSendMessage_OutboundMedia 走通一整条出站媒体链:getuploadurl → CDN 上传(POST +
|
|
// `x-encrypted-param`)→ 按官方嵌套形状组装 item → sendmessage。
|
|
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_full_url": srv.URL + "/cdn/up"})}
|
|
case req.method == http.MethodPost && req.path == "/cdn/up":
|
|
return handlerResult{
|
|
status: http.StatusOK,
|
|
body: []byte("ok"),
|
|
headers: map[string]string{uploadParamHeader: downloadParamFixture},
|
|
}
|
|
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
|
|
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
|
|
default:
|
|
return handlerResult{status: http.StatusNotFound}
|
|
}
|
|
})
|
|
|
|
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"])
|
|
}
|
|
imageItem, ok := item["image_item"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("item 不是官方的 image_item 嵌套形状:%+v", item)
|
|
}
|
|
media := imageItem["media"].(map[string]any)
|
|
if media["encrypt_query_param"] != downloadParamFixture {
|
|
t.Fatalf("encrypt_query_param = %v, want 上传响应头带回来的凭据 %q",
|
|
media["encrypt_query_param"], downloadParamFixture)
|
|
}
|
|
key, err := parseMediaAesKey(media["aes_key"].(string))
|
|
if err != nil {
|
|
t.Fatalf("解 item 上的 aes_key: %v", err)
|
|
}
|
|
|
|
// 已上传的字节(POST 请求体)必须能解密回原始媒体。
|
|
posts := rec.find(func(q recordedReq) bool { return q.path == "/cdn/up" })
|
|
if len(posts) != 1 {
|
|
t.Fatalf("CDN 上传请求 = %d 次, want 1", len(posts))
|
|
}
|
|
if posts[0].method != http.MethodPost {
|
|
t.Fatalf("上传方法 = %s, want POST(官方口径)", posts[0].method)
|
|
}
|
|
decrypted, err := aesECBDecrypt(key, posts[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
|
|
}
|