Files
Cyrene_v2/plugins/channels/weixin_ilink/send_media.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

350 lines
15 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
// 本文件承载**出站媒体的上传与 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 是 `<kind>_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
}