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)。 配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。 (逐项测试、变异红行与诚实边界见该切片报告)
This commit is contained in:
1 parent
59a1ac4056
commit
f8f2e9c54c
7 files changed
+847
-246
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user