按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐: ① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param; ③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key, 图片优先 image_item.aeskey)。 配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。 (逐项测试、变异红行与诚实边界见该切片报告)
350 lines
15 KiB
Go
350 lines
15 KiB
Go
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
|
||
}
|