Files
AskaEth f8f2e9c54c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin_ilink): 出站媒体走通(主人已批的三项)
按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐:
① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param;
③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key,
图片优先 image_item.aeskey)。
配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。
(逐项测试、变异红行与诚实边界见该切片报告)
2026-10-05 08:04:50 +08:00

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
}