Files
Cyrene_v2/plugins/channels/weixin_ilink/send_media_upload_test.go
T
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

423 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
// 本文件锁定**出站媒体链的三项官方口径**(@tencent-weixin/openclaw-weixin 2.4.9):
//
// ① 上传用 **POST**(不是 PUT)—— src/cdn/cdn-upload.ts:42-46
// ② 下载凭据来自响应头 `x-encrypted-param` — src/cdn/cdn-upload.ts:61
// ③ item 用官方**嵌套**形状 src/messaging/send.ts:293-303/337-347/381-392
//
// 三项各自都有 case;缺一项就等于「拿不到地址→上传→组装 item」这条链断在中间。
import (
"bytes"
"context"
"crypto/aes"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// uploadCaptureServer 是假 iLink + 假 CDN:getuploadurl 给一条上传地址,CDN 上传按
// uploads 序列依次作答(用于数尝试次数),sendmessage 回 ret:0。
type uploadCaptureServer struct {
srv *httptest.Server
rec *reqRecorder
attempts int
// uploads 是每次上传尝试的裁决;用尽后沿用最后一条。
uploads []handlerResult
// bodies 记录每次上传提交的密文。
bodies [][]byte
}
func newUploadCaptureServer(t *testing.T, uploads ...handlerResult) *uploadCaptureServer {
t.Helper()
s := &uploadCaptureServer{rec: &reqRecorder{}, uploads: uploads}
s.srv = newJSONServer(t, s.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": s.srv.URL + "/cdn/up"})}
case req.path == "/cdn/up":
s.attempts++
s.bodies = append(s.bodies, req.body)
i := s.attempts - 1
if i >= len(s.uploads) {
i = len(s.uploads) - 1
}
return s.uploads[i]
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}
}
})
return s
}
// okUpload 是「CDN 上传成功」的官方裁决:200 + 下载凭据头。
func okUpload(param string) handlerResult {
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: param},
}
}
// sendMedia 让适配器把一条媒体投递出去,失败即 FATAL。
func sendMedia(t *testing.T, s *uploadCaptureServer, md adapter.Media) {
t.Helper()
ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Media: []adapter.Media{md}}},
}); err != nil {
t.Fatalf("SendMessage(media): %v", err)
}
}
// mediaOfSentItem 取出 sendmessage 里那条媒体 item 的 `media` 子对象。
func mediaOfSentItem(t *testing.T, rec *reqRecorder, sub string) map[string]any {
t.Helper()
items := outboundItems(t, rec)
if len(items) != 1 {
t.Fatalf("item_list = %d 条, want 1", len(items))
}
item, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("item 不是对象:%+v", items[0])
}
inner, ok := item[sub].(map[string]any)
if !ok {
t.Fatalf("item 缺少官方的 %s 嵌套(实际键:%v)", sub, item)
}
media, ok := inner["media"].(map[string]any)
if !ok {
t.Fatalf("%s 缺少 media 子对象:%+v", sub, inner)
}
return media
}
// TestOutboundMedia_ItemUsesOfficialNestedShape 覆盖 ③:item 必须是
// `<kind>_item.media{encrypt_query_param, aes_key, encrypt_type}` 的嵌套形状,且**不再**
// 出现旧实现的平铺 cdn_ref/url/aes_key(那是逆向稿猜测,官方 send.ts 从不发)。
func TestOutboundMedia_ItemUsesOfficialNestedShape(t *testing.T) {
t.Parallel()
const param = "dl+param/file="
s := newUploadCaptureServer(t, okUpload(param))
payload := []byte("文件正文")
sendMedia(t, s, adapter.Media{Kind: adapter.MediaFile, Data: payload, Name: "report.txt"})
items := outboundItems(t, s.rec)
item := items[0].(map[string]any)
for _, stale := range []string{"cdn_ref", "url", "aes_key", "file_name", "size", "mime"} {
if _, found := item[stale]; found {
t.Fatalf("item 仍带平铺键 %q(旧猜测形态):%+v", stale, item)
}
}
media := mediaOfSentItem(t, s.rec, "file_item")
if media["encrypt_query_param"] != param {
t.Fatalf("encrypt_query_param = %v, want 上传响应头带回来的凭据 %q", media["encrypt_query_param"], param)
}
if media["encrypt_type"] != float64(encryptTypePacked) {
t.Fatalf("encrypt_type = %v, want %d", media["encrypt_type"], encryptTypePacked)
}
// `len` 是**明文**体积的十进制字符串(官方 send.ts:390 `String(fileSize)`)。
fileItem := item["file_item"].(map[string]any)
if fileItem["len"] != strconv.Itoa(len(payload)) {
t.Fatalf("file_item.len = %v, want %q", fileItem["len"], strconv.Itoa(len(payload)))
}
if fileItem["file_name"] != "report.txt" {
t.Fatalf("file_item.file_name = %v, want report.txt", fileItem["file_name"])
}
// `aes_key` 必须是 base64(32 位十六进制串)(官方 send.ts:386),且它解出的密钥必须
// 正好能解开我们 POST 上去的那份密文 —— 形状对了、值对不上同样是坏链。
encoded, ok := media["aes_key"].(string)
if !ok {
t.Fatalf("aes_key 不是字符串:%v", media["aes_key"])
}
key, err := parseMediaAesKey(encoded)
if err != nil {
t.Fatalf("aes_key 不符合官方两种编码之一:%v", err)
}
if len(s.bodies) != 1 {
t.Fatalf("上传提交了 %d 次, want 1", len(s.bodies))
}
plain, err := aesECBDecrypt(key, s.bodies[0])
if err != nil {
t.Fatalf("item 上的 aes_key 解不开上传的密文:%v", err)
}
if !bytes.Equal(plain, payload) {
t.Fatalf("解出的明文 = %q, want %q", plain, payload)
}
}
// TestOutboundMedia_UploadUsesPost 覆盖 ①:上传必须是 POST。官方 cdn-upload.ts:42-46
// 用 fetch(url, {method:"POST"});旧实现用 PUT,真机上必然被 CDN 拒绝。
func TestOutboundMedia_UploadUsesPost(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, okUpload("p1"))
sendMedia(t, s, adapter.Media{Kind: adapter.MediaImage, Data: []byte("jpeg"), Name: "a.jpg"})
uploads := s.rec.find(func(q recordedReq) bool { return q.path == "/cdn/up" })
if len(uploads) != 1 {
t.Fatalf("CDN 上传请求 = %d 次, want 1", len(uploads))
}
if uploads[0].method != http.MethodPost {
t.Fatalf("CDN 上传方法 = %s, want POST", uploads[0].method)
}
if uploads[0].ctype != "application/octet-stream" {
t.Fatalf("上传 Content-Type = %q, want application/octet-stream", uploads[0].ctype)
}
if puts := s.rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }); len(puts) != 0 {
t.Fatalf("出现了 %d 次 PUT,官方口径不使用 PUT", len(puts))
}
}
// TestOutboundMedia_DownloadParamComesFromResponseHeader 覆盖 ②:item 的
// encrypt_query_param 必须来自上传响应头 `x-encrypted-param`,**而不是**上传地址本身
// (旧实现把上传 URL 当引用填进去 —— 那是 CDN 的写入口,对方拿到也下不下来)。
func TestOutboundMedia_DownloadParamComesFromResponseHeader(t *testing.T) {
t.Parallel()
const param = "from-header-42"
s := newUploadCaptureServer(t, okUpload(param))
sendMedia(t, s, adapter.Media{Kind: adapter.MediaImage, Data: []byte("jpeg"), Name: "a.jpg"})
media := mediaOfSentItem(t, s.rec, "image_item")
if media["encrypt_query_param"] != param {
t.Fatalf("encrypt_query_param = %v, want 响应头值 %q", media["encrypt_query_param"], param)
}
if media["encrypt_query_param"] == s.srv.URL+"/cdn/up" {
t.Fatal("encrypt_query_param 是上传地址本身(写入口不能当下载引用)")
}
// 图片的体积字段是 mid_size,且必须是**密文**长度(官方 send.ts:301)。
imageItem := outboundItems(t, s.rec)[0].(map[string]any)["image_item"].(map[string]any)
if imageItem["mid_size"] != float64(len(s.bodies[0])) {
t.Fatalf("image_item.mid_size = %v, want 密文长度 %d", imageItem["mid_size"], len(s.bodies[0]))
}
// 官方出站**不**设 image_item.aeskey(那是入站优先分支用的),不得臆造。
if _, found := imageItem["aeskey"]; found {
t.Fatalf("image_item 多了官方出站没有的 aeskey 字段:%+v", imageItem)
}
}
// TestOutboundMedia_VideoItemSizeField 钉住视频的体积字段名与语义:`video_item.video_size`
// = 密文长度(官方 send.ts:345)。
func TestOutboundMedia_VideoItemSizeField(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, okUpload("p2"))
sendMedia(t, s, adapter.Media{Kind: adapter.MediaVideo, Data: []byte("mp4-bytes"), Name: "v.mp4"})
mediaOfSentItem(t, s.rec, "video_item")
item := outboundItems(t, s.rec)[0].(map[string]any)
videoItem := item["video_item"].(map[string]any)
if videoItem["video_size"] != float64(len(s.bodies[0])) {
t.Fatalf("video_item.video_size = %v, want 密文长度 %d", videoItem["video_size"], len(s.bodies[0]))
}
// 文件才有的字段不得出现在视频 item 上。
if _, found := videoItem["len"]; found {
t.Fatalf("video_item 混入了文件字段 len:%+v", videoItem)
}
}
// TestOutboundMedia_UploadWithoutParamHeaderIsHonest 覆盖 ④-a:200 但没有
// `x-encrypted-param` 头 ⇒ 必须**失败**,绝不能拿上传地址冒充下载凭据当成功。
// 官方 cdn-upload.ts:62-65 同样是「缺头即失败」。
func TestOutboundMedia_UploadWithoutParamHeaderIsHonest(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, handlerResult{status: http.StatusOK, body: []byte("ok")})
ad := newLoggingSendAdapter(t, s.srv, &bytes.Buffer{})
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
const reply = "文本必须照发"
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Content: reply,
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if err != nil {
t.Fatalf("缺凭据头不该连累整条回复:%v", err)
}
items := outboundItems(t, s.rec)
if len(items) != 1 || intOf(items[0].(map[string]any)["type"]) != itemText {
t.Fatalf("item_list = %+v, want 只剩文本", items)
}
if got := items[0].(map[string]any)["text_item"].(map[string]any)["text"]; got != reply {
t.Fatalf("发出的文本 = %q, want %q", got, reply)
}
if s.attempts != uploadMaxAttempts {
t.Fatalf("上传尝试 = %d 次, want %d(缺头属于可重试失败)", s.attempts, uploadMaxAttempts)
}
sent := s.rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/sendmessage" })
if len(sent) == 0 {
t.Fatal("文本消息没有发出去")
}
}
// TestOutboundMedia_UploadClientErrorIsNotRetried 覆盖 ④-b:4xx 是客户端错误,重试徒劳
// (官方 cdn-upload.ts:47-52 立即抛出),且服务端的 `x-error-message` 必须进错误信息 ——
// 失败要可诊断。
func TestOutboundMedia_UploadClientErrorIsNotRetried(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, handlerResult{
status: http.StatusBadRequest,
body: []byte("ignored"),
headers: map[string]string{"x-error-message": "bad filekey"},
})
ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if err == nil {
t.Fatal("4xx 上传失败却报成功")
}
if !strings.Contains(err.Error(), "bad filekey") {
t.Fatalf("错误里没有服务端说明:%v", err)
}
if s.attempts != 1 {
t.Fatalf("4xx 被重试了:上传尝试 %d 次, want 1", s.attempts)
}
}
// TestOutboundMedia_UploadServerErrorIsRetried 覆盖官方重试口径:非 4xx 失败重试到成功,
// 总尝试次数不超过 UPLOAD_MAX_RETRIES=3(官方 cdn-upload.ts:40-83)。
func TestOutboundMedia_UploadServerErrorIsRetried(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t,
handlerResult{status: http.StatusInternalServerError, body: []byte("boom")},
okUpload("recovered"),
)
sendMedia(t, s, adapter.Media{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"})
if s.attempts != 2 {
t.Fatalf("上传尝试 = %d 次, want 2(先 500 再 200)", s.attempts)
}
media := mediaOfSentItem(t, s.rec, "file_item")
if media["encrypt_query_param"] != "recovered" {
t.Fatalf("重试成功后凭据 = %v, want recovered", media["encrypt_query_param"])
}
}
// TestUploadCiphertext_ExhaustsAttempts 钉住上限:一直 5xx 时最多尝试
// uploadMaxAttempts 次就诚实失败(不无限重试)。
func TestUploadCiphertext_ExhaustsAttempts(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, handlerResult{status: http.StatusBadGateway, body: []byte("nope")})
ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv)
_, err := ad.uploadCiphertext(context.Background(), s.srv.URL+"/cdn/up", []byte("cipher"))
if err == nil {
t.Fatal("一直 5xx 却报成功")
}
if s.attempts != uploadMaxAttempts {
t.Fatalf("上传尝试 = %d 次, want %d", s.attempts, uploadMaxAttempts)
}
}
// TestEncodeMediaAesKey_RoundTripsThroughInboundParser 是形状与取值的**互逆**用例:
// 出站写出的 `aes_key` 必须能被本包入站的官方口径解析回同一把密钥(对方按官方解码,
// 我们按官方编码,两边不能各说各话)。
func TestEncodeMediaAesKey_RoundTripsThroughInboundParser(t *testing.T) {
t.Parallel()
key := keyOf(0x5A)
encoded := encodeMediaAesKey(key)
// 官方编码 = base64(32 位十六进制串):先验形状。
raw, err := decodeBase64Flexible(encoded)
if err != nil {
t.Fatalf("decodeBase64Flexible: %v", err)
}
if len(raw) != 32 || !isHexASCII(raw) {
t.Fatalf("编码后不是 32 位十六进制串的 base64:%q -> %q", encoded, raw)
}
got, err := parseMediaAesKey(encoded)
if err != nil {
t.Fatalf("parseMediaAesKey: %v", err)
}
if !bytes.Equal(got, key) {
t.Fatalf("往返后密钥 = %x, want %x", got, key)
}
if hex.EncodeToString(got) != hex.EncodeToString(key) {
t.Fatal("往返后十六进制形态不一致")
}
}
// TestUploadCiphertext_MissingHeaderErrorIsIdentifiable 钉住可判定性:缺凭据头必须以
// ErrNoUploadParam 上报,而不是笼统的字符串错误(上层要能据此区分原因)。
func TestUploadCiphertext_MissingHeaderErrorIsIdentifiable(t *testing.T) {
t.Parallel()
s := newUploadCaptureServer(t, handlerResult{status: http.StatusOK, body: []byte("ok")})
ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv)
_, err := ad.uploadCiphertext(context.Background(), s.srv.URL+"/cdn/up", []byte("cipher"))
if !errors.Is(err, ErrNoUploadParam) {
t.Fatalf("err = %v, want ErrNoUploadParam", err)
}
if errors.Is(err, ErrNoUploadURL) || errors.Is(err, ErrSessionRejected) {
t.Fatalf("缺凭据头被误判成别的失败:%v", err)
}
}
// TestNativeMediaItem_VoiceHasNoInventedSizeField 钉住「不臆造」:官方没有出站语音的
// 实现(src/messaging/send-media.ts:32-58 只路由视频/图片/文件),因此语音 item 只给
// `voice_item.media`,不编造体积字段。
func TestNativeMediaItem_VoiceHasNoInventedSizeField(t *testing.T) {
t.Parallel()
key := keyOf(0x7B)
item := nativeMediaItem(adapter.Media{Kind: adapter.MediaVoice, Name: "v.silk"}, uploadedMedia{
downloadParam: "p", aesKey: key, rawSize: 3, cipherSize: 16,
})
if item["type"] != itemVoice {
t.Fatalf("type = %v, want %d", item["type"], itemVoice)
}
voiceItem, ok := item["voice_item"].(map[string]any)
if !ok {
t.Fatalf("缺少 voice_item:%+v", item)
}
if _, found := voiceItem["media"]; !found {
t.Fatalf("voice_item 缺少 media:%+v", voiceItem)
}
for _, invented := range []string{"mid_size", "video_size", "len"} {
if _, found := voiceItem[invented]; found {
t.Fatalf("语音 item 臆造了体积字段 %q:%+v", invented, voiceItem)
}
}
if len(item) != 2 {
t.Fatalf("语音 item 顶层键 = %v, want 只有 type + voice_item", item)
}
}
// TestNativeMediaItem_EncryptsNothingAboutCredentials 是红线自查:item 里绝不能出现
// bot_token 或上传地址本身 —— 唯一允许出现的凭据是 CDN 的下载参数与本次随机密钥。
func TestNativeMediaItem_EncryptsNothingAboutCredentials(t *testing.T) {
t.Parallel()
key := make([]byte, aes.BlockSize)
for i := range key {
key[i] = byte(i)
}
item := nativeMediaItem(adapter.Media{Kind: adapter.MediaFile, Name: "1.txt"}, uploadedMedia{
downloadParam: "param-only", aesKey: key, rawSize: 1, cipherSize: 16,
})
fileItem := item["file_item"].(map[string]any)
media := fileItem["media"].(map[string]any)
if len(media) != 3 {
t.Fatalf("media 键 = %v, want 恰好 encrypt_query_param/aes_key/encrypt_type", media)
}
if _, found := media["full_url"]; found {
t.Fatal("官方出站不设 full_url,不得臆造")
}
}