Files
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00

351 lines
15 KiB
Go
Raw Permalink 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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
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
// 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`;
// 本项目**不把它拼进错误** —— 上游自由文本面一律默认不带,见 uploadOnce),
// 其余失败最多重试 3 次(无退避);
// - `src/cdn/cdn-upload.ts:62-65`:200 但没有该响应头同样是失败(不得当成成功)。
//
// 日志只带**来源主机**:uploadURL 是服务端签发的预签名地址(`encrypted_query_param`
// 就是下载凭据),整条写进日志等于把凭据写进日志。
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,
"host", mediafetch.HostOf(uploadURL), "err", err)
}
return "", fmt.Errorf("weixin_ilink: upload media: %d 次尝试均失败: %w", uploadMaxAttempts, lastErr)
}
// uploadOnce 做一次上传尝试,返回下载凭据。retryable 报告这次失败是否值得重试:
// 4xx 是客户端错误(请求本身不对),重试只是徒劳;网络/5xx/缺头都可能瞬时。
//
// 错误里只留来源主机:上传地址是服务端签发的预签名 URL,而 `*url.Error` 会把整条
// 地址(含 `encrypted_query_param`)拼进错误串 —— 这条错误还要经 send.go 的
// `outbound media item skipped` 落日志,因此必须在**产生错误的那一步**剥掉。
func (a *Adapter) uploadOnce(ctx context.Context, uploadURL string, data []byte) (string, bool, error) {
host := mediafetch.HostOf(uploadURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(data))
if err != nil {
return "", false, fmt.Errorf("weixin_ilink: build upload to %s: %w", host, trace.URLCause(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 to %s: %w", host, trace.URLCause(err))
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
// 服务端把原因放在 x-error-message 头里(官方 cdn-upload.ts:48)。它是**上游自由
// 文本面**(与信封 message / Bot API description / OneBot msg 同族),因此
// **默认不进**错误与日志(M50 口径):上游可以在里面回显我们上传的东西,而同一道
// 边界在别处才刚收紧过。status 已经足以分类(4xx = 请求本身不对,立即放弃),
// 所以这里只留 status。
return "", false, fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode)
}
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)))
}
// 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
}