diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index 4ed4ce9..1960e7e 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -92,6 +92,9 @@ var ( // 的上传参数时返回。它与 ErrSessionRejected 是两类失败:前者是协议字段缺失,后者 // 是凭据被 iLink 拒绝(必须重新扫码)。 ErrNoUploadURL = errors.New("weixin_ilink: getuploadurl returned no upload url") + // ErrNoUploadParam 在 CDN 上传返回 200、却没有 `x-encrypted-param` 响应头时返回。 + // 没有这个凭据就无法告诉对方去哪里下载,**绝不能**拿上传地址冒充它当成功。 + ErrNoUploadParam = errors.New("weixin_ilink: cdn upload response carries no " + uploadParamHeader + " header") ) // Config 保存 weixin-ilink 适配器的设置。它由渠道层从环境/配置读取,并可通过 diff --git a/plugins/channels/weixin_ilink/fake_test.go b/plugins/channels/weixin_ilink/fake_test.go index da52509..e7cff81 100644 --- a/plugins/channels/weixin_ilink/fake_test.go +++ b/plugins/channels/weixin_ilink/fake_test.go @@ -14,11 +14,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// handlerResult 是测试里的假处理器对一次请求给出的裁决。 +// handlerResult 是测试里的假处理器对一次请求给出的裁决。headers 用于模拟 CDN 上传 +// 成功时回填凭据的响应头(`x-encrypted-param`)。 type handlerResult struct { - status int - body []byte - ctype string + status int + body []byte + ctype string + headers map[string]string } // reqRecorder 捕获假服务端收到的请求,使测试能断言适配器发送了预期的游标、 @@ -34,6 +36,7 @@ type recordedReq struct { path string query string body []byte + ctype string auth string authType string uin string @@ -108,6 +111,7 @@ func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handle path: r.URL.Path, query: r.URL.RawQuery, body: body, + ctype: r.Header.Get("Content-Type"), auth: r.Header.Get("Authorization"), authType: r.Header.Get("AuthorizationType"), uin: r.Header.Get("X-WECHAT-UIN"), @@ -125,6 +129,9 @@ func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handle res.status = http.StatusOK } w.Header().Set("Content-Type", res.ctype) + for k, v := range res.headers { + w.Header().Set(k, v) + } w.WriteHeader(res.status) _, _ = w.Write(res.body) })) diff --git a/plugins/channels/weixin_ilink/send.go b/plugins/channels/weixin_ilink/send.go index 011e5a5..74d9c53 100644 --- a/plugins/channels/weixin_ilink/send.go +++ b/plugins/channels/weixin_ilink/send.go @@ -3,39 +3,24 @@ package weixin_ilink import ( "bytes" "context" - "crypto/aes" - "crypto/md5" "crypto/rand" - "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" - "net/url" - "sort" "strconv" - "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// item 类型常量复用 poll.go 里的 iLink item 类型码,使出站媒体与入站解析映射到 -// 同一组数字。 +// 本文件承载**发送编排**:把一条归一化回复切成 item_list 并投递。媒体上传与 item 组装 +// 在 send_media.go(本文件保持在项目 ≤500 行的单文件上限之内)。 const ( - // clientIDBytes / fileKeyBytes 是两处随机标识的字节数。取具名常量,是因为它们 - // 分别对应协议里的 client_id(去重 ID)与 getuploadurl 的 filekey,不是随手取的数。 + // clientIDBytes 是发送去重 ID(client_id)的随机字节数:它对应协议里的 client_id, + // 不是随手取的数字。 clientIDBytes = 12 - fileKeyBytes = 16 - - // getuploadurl 的 media_type 码(官方 UploadMediaType)。⚠️ 它与 item_list 的 - // type 码**不是同一套**:这里 1=图片 2=视频 3=文件 4=语音,而 item 码是 - // 2=图片 4=文件 3=语音 5=视频。照 item 码填会让服务端按错误的类型签上传地址。 - uploadMediaImage = 1 - uploadMediaVideo = 2 - uploadMediaFile = 3 - uploadMediaVoice = 4 ) // sendResult 是 sendmessage 的响应外壳。 @@ -146,56 +131,6 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM return a.sendNative(ctx, peer, ctxToken, items) } -// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象及其 AES 密钥的 -// 原生 item。 -func (a *Adapter) buildMediaItem(ctx context.Context, peer string, md adapter.Media) (any, error) { - plaintext := md.Data - if len(plaintext) == 0 && md.URL != "" { - data, err := a.fetchBody(ctx, md.URL) - if err != nil { - return nil, fmt.Errorf("weixin_ilink: fetch media source: %w", err) - } - plaintext = data - } - - key := make([]byte, aes.BlockSize) - if _, err := rand.Read(key); err != nil { - return nil, fmt.Errorf("weixin_ilink: generate aes key: %w", err) - } - encrypted, err := aesECBEncrypt(key, plaintext) - if err != nil { - return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err) - } - uploadURL, err := a.getUploadURL(ctx, uploadRequest{ - peer: peer, - mediaType: uploadMediaTypeFor(md.Kind), - rawSize: int64(len(plaintext)), - rawMD5: md5Hex(plaintext), - cipherSize: int64(len(encrypted)), - aesKey: key, - }) - if err != nil { - return nil, fmt.Errorf("weixin_ilink: get upload url: %w", err) - } - if err := a.putBytes(ctx, uploadURL, encrypted); err != nil { - return nil, fmt.Errorf("weixin_ilink: upload media: %w", err) - } - return nativeMediaItem(nativeMediaTypeFor(md.Kind), uploadURL, key, md), nil -} - -// uploadRequest 是 getuploadurl 需要的按条参数(官方 GetUploadUrlReq 里我们能诚实填出 -// 的子集)。少了这些字段,服务端只会回一个既没有 upload_full_url、也没有 upload_param -// 的空壳 —— 真机日志里的「getuploadurl returned no url」就是这么来的:旧实现发的是 -// 空 body。 -type uploadRequest struct { - peer string - mediaType int - rawSize int64 - rawMD5 string - cipherSize int64 - aesKey []byte -} - // sendNative 提交一份组装好的 sendmessage 载荷。 func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items []any) error { body, err := json.Marshal(map[string]any{ @@ -228,84 +163,6 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [ return nil } -// getUploadURL 申请一条 CDN 上传地址。官方 GetUploadUrlResp 给的是 -// `upload_full_url`(服务端直接给的完整地址)与 `upload_param`(需按官方 -// src/cdn/cdn-url.ts 的 buildCdnUploadUrl 拼到 CDN 根下);`upload_url`/`url`/ -// `uploadUrl`/`cdn_url` 是早期逆向稿的猜测名,保留为兜底,以免下一版服务端换了名 -// 就被判死。 -// -// 「键缺失」与「会话被拒」是两类失败,必须分开:前者是协议字段对不上(可重试、需对表), -// 后者是凭据被 iLink 拒绝(只能重新扫码,且必须记进被拒状态)。缺键时把**键名集合** -// 记进日志 —— 只记键名、绝不记值 —— 让下一次真机失败能直接对表。 -func (a *Adapter) getUploadURL(ctx context.Context, req uploadRequest) (string, error) { - // filekey 既是请求字段,也是 upload_param 拼接路径的组成部分,因此必须先于请求 - // 生成、并贯穿到返回的地址里(官方 src/cdn/upload.ts 同法)。 - fileKey := randomHex(fileKeyBytes) - body, err := json.Marshal(map[string]any{ - "filekey": fileKey, - "media_type": req.mediaType, - "to_user_id": req.peer, - "rawsize": req.rawSize, - "rawfilemd5": req.rawMD5, - "filesize": req.cipherSize, - "no_need_thumb": true, // 我们不上传缩略图,明确告知服务端不必签发它的地址 - "aeskey": hex.EncodeToString(req.aesKey), - }) - if err != nil { - return "", fmt.Errorf("weixin_ilink: encode getuploadurl: %w", err) - } - var raw map[string]any - if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/getuploadurl"), - bytes.NewReader(body), &raw); err != nil { - return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err) - } - if intOf(raw["errcode"]) == errcodeSessionRejected { - // 媒体上传属于发送路径,所以这里同样记录这份被拒凭据,而不是退化成 - //「未返回 url」。 - a.markRejected(rescanReasonUploadURL) - return "", ErrSessionRejected - } - if u := strings.TrimSpace(firstString(raw, "upload_full_url")); u != "" { - return u, nil - } - if param := firstString(raw, "upload_param"); param != "" { - return a.uploadURLFromParam(param, fileKey), nil - } - if u := strings.TrimSpace(firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")); u != "" { - return u, nil - } - a.log.Warn("getuploadurl returned no upload address", - "peer", req.peer, "media_type", req.mediaType, - "keys", sortedKeys(raw), "ret", intOf(raw["ret"]), "errcode", intOf(raw["errcode"])) - return "", ErrNoUploadURL -} - -// uploadURLFromParam 按官方 buildCdnUploadUrl 把上传参数拼成完整地址。两个参数都必须 -// 转义:它们是 base64/十六进制串,未转义的 `+` 会被服务端当成空格。 -func (a *Adapter) uploadURLFromParam(param, fileKey string) string { - return strings.TrimRight(a.cfg.CDNBaseURL, "/") + - "/upload?encrypted_query_param=" + url.QueryEscape(param) + - "&filekey=" + url.QueryEscape(fileKey) -} - -// putBytes 把加密后的媒体字节上传到预签名 CDN URL。 -func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("weixin_ilink: build upload: %w", err) - } - req.Header.Set("Content-Type", "application/octet-stream") - resp, err := a.httpClient.Do(req) - if err != nil { - return fmt.Errorf("weixin_ilink: upload media: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode) - } - return nil -} - // nativeTextItem 构造一个文本 item_list 条目。 func nativeTextItem(text string) map[string]any { return map[string]any{ @@ -314,58 +171,6 @@ func nativeTextItem(text string) map[string]any { } } -// nativeMediaItem 构造一个引用已上传对象的媒体 item_list 条目。同时设置 cdn_ref -// 与 url,因为确切的字段名并未公开。 -func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[string]any { - item := map[string]any{ - "type": kind, - "cdn_ref": ref, - "url": ref, - "aes_key": base64.StdEncoding.EncodeToString(key), - "file_name": md.Name, - "size": md.Size, - } - if md.MIMEType != "" { - item["mime"] = md.MIMEType - } - return item -} - -// nativeMediaTypeFor 把归一化的 MediaKind 映射到 iLink item 类型码。 -func nativeMediaTypeFor(kind adapter.MediaKind) int { - switch kind { - case adapter.MediaImage: - return itemImage - case adapter.MediaVoice: - return itemVoice - case adapter.MediaVideo: - return itemVideo - default: - return itemFile - } -} - -// uploadMediaTypeFor 把归一化的 MediaKind 映射到 getuploadurl 的 media_type 码。 -func uploadMediaTypeFor(kind adapter.MediaKind) int { - switch kind { - case adapter.MediaImage: - return uploadMediaImage - case adapter.MediaVoice: - return uploadMediaVoice - case adapter.MediaVideo: - return uploadMediaVideo - default: - return uploadMediaFile - } -} - -// md5Hex 返回载荷的十六进制 MD5。**不是安全用途**:官方 getuploadurl 的 `rawfilemd5` -// 字段就要求这个值(src/cdn/upload.ts),属于协议的内容校验字段。 -func md5Hex(data []byte) string { - sum := md5.Sum(data) - return hex.EncodeToString(sum[:]) -} - // newClientID 返回随机的十六进制 client id,用于发送去重。 func newClientID() string { return randomHex(clientIDBytes) } @@ -378,14 +183,3 @@ func randomHex(n int) string { } return strconv.FormatInt(time.Now().UnixNano(), 36) } - -// sortedKeys 返回一个 JSON 对象的键名升序列表。它只用于**日志**:键名集合足以对表 -// 「服务端到底回了什么形状」,而值(上传地址、凭据)绝不进日志。 -func sortedKeys(m map[string]any) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/plugins/channels/weixin_ilink/send_media.go b/plugins/channels/weixin_ilink/send_media.go new file mode 100644 index 0000000..73484c9 --- /dev/null +++ b/plugins/channels/weixin_ilink/send_media.go @@ -0,0 +1,349 @@ +package weixin_ilink + +// 本文件承载**出站媒体的上传与 item 组装**(从 send.go 拆出,使两个文件都留在项目 +// ≤500 行的单文件上限内):getuploadurl → CDN 上传 → 按官方嵌套形状组装 item。 +// +// 权威口径取自官方包 @tencent-weixin/openclaw-weixin 2.4.9 的 src/(2026-10-05 核对, +// AGENTS §9.5): +// - `src/cdn/upload.ts:62-121`:先把明文 MD5/明文体积/密文体积/随机 filekey/随机 +// aeskey 交给 getuploadurl,再上传密文; +// - `src/cdn/cdn-upload.ts:40-66`:上传用 **POST**(不是 PUT),200 时下载凭据在 +// 响应头 `x-encrypted-param`;4xx 立即放弃,其余失败最多重试 3 次; +// - `src/messaging/send.ts:293-303/337-347/381-392`:item 是 `_item.media{ +// encrypt_query_param, aes_key, encrypt_type}` 的**嵌套**形状,体积字段分别是 +// `mid_size`/`video_size`/`len`,`aes_key` 是 base64(32 位十六进制密钥串)。 +// 旧实现的三处偏离(PUT、忽略响应头、平铺 cdn_ref/url)在真机上都不可能走通。 + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +const ( + // fileKeyBytes 是 getuploadurl 的 filekey 字节数(官方 src/cdn/upload.ts:76 用 + // crypto.randomBytes(16).toString("hex"))。 + fileKeyBytes = 16 + + // getuploadurl 的 media_type 码(官方 UploadMediaType)。⚠️ 它与 item_list 的 + // type 码**不是同一套**:这里 1=图片 2=视频 3=文件 4=语音,而 item 码是 + // 2=图片 4=文件 3=语音 5=视频。照 item 码填会让服务端按错误的类型签上传地址。 + uploadMediaImage = 1 + uploadMediaVideo = 2 + uploadMediaFile = 3 + uploadMediaVoice = 4 + + // uploadParamHeader 是 CDN 上传成功时回填下载凭据的响应头(官方 + // src/cdn/cdn-upload.ts:61)。凭据**只**从这个头来,响应体不算数。 + uploadParamHeader = "x-encrypted-param" + // uploadMaxAttempts 是 CDN 上传的最大尝试次数(官方 cdn-upload.ts:7 的 + // UPLOAD_MAX_RETRIES=3,无退避重试)。 + uploadMaxAttempts = 3 + // uploadErrMaxChars 是进日志/错误的服务端错误说明的字符上限。 + uploadErrMaxChars = 200 + + // encryptTypePacked 是 CDNMedia.encrypt_type 的取值:1 = 打包了缩略图/中图等信息 + // (官方 src/api/types.ts:95 的注释)。官方出站三种媒体都固定填 1 + // (send.ts:299/343/387)。 + encryptTypePacked = 1 +) + +// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象的原生 item。 +func (a *Adapter) buildMediaItem(ctx context.Context, peer string, md adapter.Media) (any, error) { + plaintext := md.Data + if len(plaintext) == 0 && md.URL != "" { + data, err := a.fetchBody(ctx, md.URL) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: fetch media source: %w", err) + } + plaintext = data + } + + key := make([]byte, aes.BlockSize) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("weixin_ilink: generate aes key: %w", err) + } + encrypted, err := aesECBEncrypt(key, plaintext) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err) + } + uploadURL, err := a.getUploadURL(ctx, uploadRequest{ + peer: peer, + mediaType: uploadMediaTypeFor(md.Kind), + rawSize: int64(len(plaintext)), + rawMD5: md5Hex(plaintext), + cipherSize: int64(len(encrypted)), + aesKey: key, + }) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: get upload url: %w", err) + } + downloadParam, err := a.uploadCiphertext(ctx, uploadURL, encrypted) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: upload media: %w", err) + } + return nativeMediaItem(md, uploadedMedia{ + downloadParam: downloadParam, + aesKey: key, + rawSize: int64(len(plaintext)), + cipherSize: int64(len(encrypted)), + }), nil +} + +// uploadedMedia 是一次成功上传的结果,也是组装原生媒体 item 所需的全部素材: +// CDN 下载凭据(来自上传响应头 x-encrypted-param)、本次随机的 AES 密钥,以及明文/ +// 密文两个体积(协议里两个字段要的分别是它们,不能互相顶替)。 +type uploadedMedia struct { + downloadParam string + aesKey []byte + rawSize int64 + cipherSize int64 +} + +// uploadRequest 是 getuploadurl 需要的按条参数(官方 GetUploadUrlReq)。少了这些字段, +// 服务端只会回一个既没有 upload_full_url、也没有 upload_param 的空壳 —— 真机日志里的 +// 「getuploadurl returned no url」就是这么来的:旧实现发的是空 body。 +type uploadRequest struct { + peer string + mediaType int + rawSize int64 + rawMD5 string + cipherSize int64 + aesKey []byte +} + +// getUploadURL 申请一条 CDN 上传地址。官方 GetUploadUrlResp 给的是 +// `upload_full_url`(服务端直接给的完整地址)与 `upload_param`(需按官方 +// src/cdn/cdn-url.ts 的 buildCdnUploadUrl 拼到 CDN 根下);`upload_url`/`url`/ +// `uploadUrl`/`cdn_url` 是早期逆向稿的猜测名,保留为兜底,以免下一版服务端换了名 +// 就被判死。 +// +// 「键缺失」与「会话被拒」是两类失败,必须分开:前者是协议字段对不上(可重试、需对表), +// 后者是凭据被 iLink 拒绝(只能重新扫码,且必须记进被拒状态)。缺键时把**键名集合** +// 记进日志 —— 只记键名、绝不记值 —— 让下一次真机失败能直接对表。 +func (a *Adapter) getUploadURL(ctx context.Context, req uploadRequest) (string, error) { + // filekey 既是请求字段,也是 upload_param 拼接路径的组成部分,因此必须先于请求 + // 生成、并贯穿到返回的地址里(官方 src/cdn/upload.ts 同法)。 + fileKey := randomHex(fileKeyBytes) + body, err := json.Marshal(map[string]any{ + "filekey": fileKey, + "media_type": req.mediaType, + "to_user_id": req.peer, + "rawsize": req.rawSize, + "rawfilemd5": req.rawMD5, + "filesize": req.cipherSize, + "no_need_thumb": true, // 我们不上传缩略图,明确告知服务端不必签发它的地址 + "aeskey": hex.EncodeToString(req.aesKey), + // base_info 与 getupdates 用同一份(官方 api.ts:507 在 getuploadurl 上也带它, + // 只是 types.ts 的 GetUploadUrlReq 没把它列出来)。 + "base_info": map[string]string{"channel_version": channelVersion}, + }) + if err != nil { + return "", fmt.Errorf("weixin_ilink: encode getuploadurl: %w", err) + } + var raw map[string]any + if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/getuploadurl"), + bytes.NewReader(body), &raw); err != nil { + return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err) + } + if intOf(raw["errcode"]) == errcodeSessionRejected { + // 媒体上传属于发送路径,所以这里同样记录这份被拒凭据,而不是退化成 + //「未返回 url」。 + a.markRejected(rescanReasonUploadURL) + return "", ErrSessionRejected + } + if u := strings.TrimSpace(firstString(raw, "upload_full_url")); u != "" { + return u, nil + } + if param := firstString(raw, "upload_param"); param != "" { + return a.uploadURLFromParam(param, fileKey), nil + } + if u := strings.TrimSpace(firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")); u != "" { + return u, nil + } + a.log.Warn("getuploadurl returned no upload address", + "peer", req.peer, "media_type", req.mediaType, + "keys", sortedKeys(raw), "ret", intOf(raw["ret"]), "errcode", intOf(raw["errcode"])) + return "", ErrNoUploadURL +} + +// uploadURLFromParam 按官方 buildCdnUploadUrl 把上传参数拼成完整地址。两个参数都必须 +// 转义:它们是 base64/十六进制串,未转义的 `+` 会被服务端当成空格。 +func (a *Adapter) uploadURLFromParam(param, fileKey string) string { + return strings.TrimRight(a.cfg.CDNBaseURL, "/") + + "/upload?encrypted_query_param=" + url.QueryEscape(param) + + "&filekey=" + url.QueryEscape(fileKey) +} + +// uploadCiphertext 把加密后的媒体字节上传到 getuploadurl 给出的 CDN 地址,并返回 +// **CDN 回给我们的下载凭据**。 +// +// 官方依据(@tencent-weixin/openclaw-weixin 2.4.9): +// - `src/cdn/cdn-upload.ts:40-66`:用 **POST**(不是 PUT)提交密文,`Content-Type: +// application/octet-stream`;只有 200 才算成功; +// - `src/cdn/cdn-upload.ts:61`:下载凭据在**响应头 `x-encrypted-param`** 里,拿到后 +// 填进 item 的 `media.encrypt_query_param`(`src/cdn/upload.ts:17` 的注释写明); +// 响应体本身不是凭据来源 —— 这正是旧实现「PUT 上去就当成功」丢掉的那一环; +// - `src/cdn/cdn-upload.ts:40-83`:4xx 立即放弃(附服务端 `x-error-message`), +// 其余失败最多重试 3 次(无退避); +// - `src/cdn/cdn-upload.ts:62-65`:200 但没有该响应头同样是失败(不得当成成功)。 +func (a *Adapter) uploadCiphertext(ctx context.Context, uploadURL string, data []byte) (string, error) { + var lastErr error + for attempt := 1; attempt <= uploadMaxAttempts; attempt++ { + param, retryable, err := a.uploadOnce(ctx, uploadURL, data) + if err == nil { + return param, nil + } + lastErr = err + if !retryable { + return "", lastErr + } + a.log.Warn("cdn upload attempt failed, retrying", + "attempt", attempt, "of", uploadMaxAttempts, "err", err) + } + return "", fmt.Errorf("weixin_ilink: upload media: %d 次尝试均失败: %w", uploadMaxAttempts, lastErr) +} + +// uploadOnce 做一次上传尝试,返回下载凭据。retryable 报告这次失败是否值得重试: +// 4xx 是客户端错误(请求本身不对),重试只是徒劳;网络/5xx/缺头都可能瞬时。 +func (a *Adapter) uploadOnce(ctx context.Context, uploadURL string, data []byte) (string, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(data)) + if err != nil { + return "", false, fmt.Errorf("weixin_ilink: build upload: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := a.httpClient.Do(req) + if err != nil { + return "", true, fmt.Errorf("weixin_ilink: upload media: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + // 服务端把原因放在 x-error-message 头里(官方 cdn-upload.ts:48);它是对我们 + // 这次请求的说明,不是凭据,可以进错误与日志(截断,避免服务端回显灌爆日志)。 + return "", false, fmt.Errorf("weixin_ilink: upload media: status %d: %s", + resp.StatusCode, truncateForLog(resp.Header.Get("x-error-message"), uploadErrMaxChars)) + } + if resp.StatusCode != http.StatusOK { + return "", true, fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode) + } + param := strings.TrimSpace(resp.Header.Get(uploadParamHeader)) + if param == "" { + return "", true, fmt.Errorf("%w: 200 响应缺少 %s 头", ErrNoUploadParam, uploadParamHeader) + } + return param, false, nil +} + +// nativeMediaItem 按官方口径组装一个引用已上传对象的媒体 item_list 条目。 +// +// 官方依据(@tencent-weixin/openclaw-weixin 2.4.9 的 src/messaging/send.ts): +// - 图片 `send.ts:293-303`:`image_item.media{encrypt_query_param, aes_key, +// encrypt_type:1}` + `image_item.mid_size = 密文长度`; +// - 视频 `send.ts:337-347`:同形状,体积字段叫 `video_item.video_size`; +// - 文件 `send.ts:381-392`:`file_item{media{…}, file_name}` + +// `file_item.len = String(明文长度)`(**字符串**,与入站解析同一字段); +// - 三者的 `aes_key` 都是 `Buffer.from(hex(key), "base64")`,即 +// **base64(32 位十六进制串)** —— 与本包入站 `parseMediaAesKey` 的第二分支互逆, +// 对方按官方口径解得出密钥; +// - 官方**不**设置平铺的 cdn_ref/url,也**不**设 full_url:引用只有 +// `media.encrypt_query_param` 这一处(`full_url` 只是服务端**下发**时可给的便利 +// 字段)。旧实现的平铺形态是逆向稿猜测,已废弃。 +// +// 语音没有官方出站实现(`src/messaging/send-media.ts:32-58` 只路由视频/图片/文件), +// 因此这里只按入站 `VoiceItem{media}` 的形状给出 `voice_item.media`,不臆造体积字段。 +func nativeMediaItem(md adapter.Media, up uploadedMedia) map[string]any { + media := map[string]any{ + "encrypt_query_param": up.downloadParam, + "aes_key": encodeMediaAesKey(up.aesKey), + "encrypt_type": encryptTypePacked, + } + switch md.Kind { + case adapter.MediaImage: + return map[string]any{ + "type": itemImage, + "image_item": map[string]any{"media": media, "mid_size": up.cipherSize}, + } + case adapter.MediaVideo: + return map[string]any{ + "type": itemVideo, + "video_item": map[string]any{"media": media, "video_size": up.cipherSize}, + } + case adapter.MediaVoice: + return map[string]any{ + "type": itemVoice, + "voice_item": map[string]any{"media": media}, + } + default: + return map[string]any{ + "type": itemFile, + "file_item": map[string]any{ + "media": media, + "file_name": md.Name, + "len": strconv.FormatInt(up.rawSize, 10), + }, + } + } +} + +// encodeMediaAesKey 把 16 字节密钥编成协议要求的 `aes_key`:先转 32 位十六进制串, +// 再整体 base64(官方 send.ts:298/342/386 的 `Buffer.from(hex, "base64")`;官方入站 +// parseAesKey 的「base64(32 位十六进制)」分支正是它的逆)。 +func encodeMediaAesKey(key []byte) string { + return base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(key))) +} + +// truncateForLog 按 rune 截断一段外来文本,用于把服务端的错误说明放进日志/错误而不 +// 让它撑爆日志行。它只被喂给「服务端对我们这次请求的说明」,不是凭据。 +func truncateForLog(s string, max int) string { + trimmed := strings.TrimSpace(s) + runes := []rune(trimmed) + if len(runes) <= max { + return trimmed + } + return string(runes[:max]) + "…" +} + +// uploadMediaTypeFor 把归一化的 MediaKind 映射到 getuploadurl 的 media_type 码。 +func uploadMediaTypeFor(kind adapter.MediaKind) int { + switch kind { + case adapter.MediaImage: + return uploadMediaImage + case adapter.MediaVoice: + return uploadMediaVoice + case adapter.MediaVideo: + return uploadMediaVideo + default: + return uploadMediaFile + } +} + +// md5Hex 返回载荷的十六进制 MD5。**不是安全用途**:官方 getuploadurl 的 `rawfilemd5` +// 字段就要求这个值(src/cdn/upload.ts:74),属于协议的内容校验字段。 +func md5Hex(data []byte) string { + sum := md5.Sum(data) + return hex.EncodeToString(sum[:]) +} + +// sortedKeys 返回一个 JSON 对象的键名升序列表。它只用于**日志**:键名集合足以对表 +// 「服务端到底回了什么形状」,而值(上传地址、凭据)绝不进日志。 +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/plugins/channels/weixin_ilink/send_media_failure_test.go b/plugins/channels/weixin_ilink/send_media_failure_test.go index 6402823..87582ca 100644 --- a/plugins/channels/weixin_ilink/send_media_failure_test.go +++ b/plugins/channels/weixin_ilink/send_media_failure_test.go @@ -23,15 +23,20 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// mediaSendServer 是假 iLink:getuploadurl 按 upload 作答,上传成功,sendmessage 回 ret:0。 +// mediaSendServer 是假 iLink:getuploadurl 按 upload 作答,**CDN 上传按官方口径回 +// 200 + `x-encrypted-param` 头**(POST),sendmessage 回 ret:0。 func mediaSendServer(t *testing.T, rec *reqRecorder, upload handlerResult) *httptest.Server { t.Helper() return newJSONServer(t, rec, func(req recordedReq) handlerResult { switch { case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": return upload - case req.method == http.MethodPut: - return handlerResult{status: http.StatusOK, body: []byte("ok")} + 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: @@ -40,6 +45,10 @@ func mediaSendServer(t *testing.T, rec *reqRecorder, upload handlerResult) *http }) } +// downloadParamFixture 是假 CDN 回填的下载凭据(真实值由服务端生成,测试只关心它被 +// 原样带进 item 的 media.encrypt_query_param)。 +const downloadParamFixture = "dl+param/1=" + // newLoggingSendAdapter 装配一个把日志写进 buf 的已连接适配器,用来断言失败**不静默**。 func newLoggingSendAdapter(t *testing.T, srv *httptest.Server, buf *bytes.Buffer) *Adapter { t.Helper() @@ -193,9 +202,9 @@ func TestGetUploadURL_RequestCarriesProtocolFields(t *testing.T) { if err != nil { t.Fatalf("aeskey 不是十六进制:%v", err) } - puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }) - if len(puts) != 0 { - t.Fatalf("上传地址缺失时不该发起上传:%d 次", len(puts)) + // 上传地址缺失时**一次上传请求都不该发出**(只应有那一次 getuploadurl)。 + if reqs := rec.find(func(q recordedReq) bool { return q.method == http.MethodPost }); len(reqs) != 1 { + t.Fatalf("POST 请求 = %d 次, want 1(只有 getuploadurl;地址缺失就不该上传)", len(reqs)) } if len(rawKey) != 16 { t.Fatalf("aeskey = %d 字节, want 16", len(rawKey)) @@ -212,8 +221,12 @@ func TestOutboundMedia_UsesUploadFullURL(t *testing.T) { 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.MethodPut && req.path == "/cdn/up": - return handlerResult{status: http.StatusOK, body: []byte("ok")} + 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: @@ -248,8 +261,12 @@ func TestOutboundMedia_BuildsUploadURLFromUploadParam(t *testing.T) { switch { case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": return handlerResult{body: jsonBody(map[string]any{"upload_param": "ab+cd="})} - case req.method == http.MethodPut && req.path == "/upload": - return handlerResult{status: http.StatusOK, body: []byte("ok")} + case req.method == http.MethodPost && req.path == "/upload": + 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: @@ -268,10 +285,13 @@ func TestOutboundMedia_BuildsUploadURLFromUploadParam(t *testing.T) { t.Fatalf("SendMessage(media, upload_param): %v", err) } - puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }) + puts := rec.find(func(q recordedReq) bool { return q.path == "/upload" }) if len(puts) != 1 { t.Fatalf("上传请求 = %d 次, want 1", len(puts)) } + if puts[0].method != http.MethodPost { + t.Fatalf("上传方法 = %s, want POST(官方口径)", puts[0].method) + } if !strings.Contains(puts[0].query, "encrypted_query_param=ab%2Bcd%3D") { t.Fatalf("上传查询串 = %q, want 转义后的 encrypted_query_param", puts[0].query) } diff --git a/plugins/channels/weixin_ilink/send_media_upload_test.go b/plugins/channels/weixin_ilink/send_media_upload_test.go new file mode 100644 index 0000000..da5e04f --- /dev/null +++ b/plugins/channels/weixin_ilink/send_media_upload_test.go @@ -0,0 +1,422 @@ +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 必须是 +// `_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,不得臆造") + } +} diff --git a/plugins/channels/weixin_ilink/send_test.go b/plugins/channels/weixin_ilink/send_test.go index 1c251aa..cdc640d 100644 --- a/plugins/channels/weixin_ilink/send_test.go +++ b/plugins/channels/weixin_ilink/send_test.go @@ -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) }