feat(weixin_ilink): 出站媒体走通(主人已批的三项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐:
① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param;
③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key,
图片优先 image_item.aeskey)。
配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。
(逐项测试、变异红行与诚实边界见该切片报告)
This commit is contained in:
AskaEth committed 2026-10-05 08:04:50 +08:00
1 parent 59a1ac4056
commit f8f2e9c54c
7 files changed
+847 -246

No files matched your search

+27 -21
View File
@@ -3,8 +3,6 @@ package weixin_ilink
import (
"bytes"
"context"
"crypto/aes"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
@@ -146,6 +144,8 @@ func TestSendProactive_NoPriorInbound(t *testing.T) {
}
}
// TestSendMessage_OutboundMedia 走通一整条出站媒体链:getuploadurl → CDN 上传(POST +
// `x-encrypted-param`)→ 按官方嵌套形状组装 item → sendmessage。
func TestSendMessage_OutboundMedia(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -153,13 +153,17 @@ func TestSendMessage_OutboundMedia(t *testing.T) {
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")}
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: 404}
return handlerResult{status: http.StatusNotFound}
}
})
@@ -183,27 +187,29 @@ func TestSendMessage_OutboundMedia(t *testing.T) {
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"])
imageItem, ok := item["image_item"].(map[string]any)
if !ok {
t.Fatalf("item 不是官方的 image_item 嵌套形状:%+v", item)
}
aesKeyEncoded, _ := item["aes_key"].(string)
if aesKeyEncoded == "" {
t.Fatal("aes_key missing on media 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 := base64.StdEncoding.DecodeString(aesKeyEncoded)
key, err := parseMediaAesKey(media["aes_key"].(string))
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)
t.Fatalf("解 item 上的 aes_key: %v", err)
}
// 已上传的字节(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))
// 已上传的字节(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))
}
decrypted, err := aesECBDecrypt(key, puts[0].body)
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)
}