fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

Bug A(入站取不到字节)真因两层:① 引用位置错——真实在
`file_item.media.encrypt_query_param`,旧 mediaOf 只扫平铺 url/cdn_url;
② 密钥来源错——官方口径是**随每条附件下发**的 `*_item.media.aes_key`
(图片优先 `image_item.aeskey`),旧代码却把整个 bot_token 当 base64 取尾 16 字节,
而真机 token 形如 `<id>@im.bot:<38hex>`,第 12 字符 `@` 处即非法。
(对照证据:官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码,只读未入库;
design/08 §3.3 的「token base64 尾 16 字节」据此判定为错误口径,待文档更正。)
Bug B(出站):① sendToOne 遇媒体构建失败直接 return ⇒ 连文本一起丢(改为逐条跳过
+ 带来源 WARN;全部媒体失败仍发文本,无文本且全挂才诚实报错);② getUploadURL 发空
body 且只认 4 个猜测键 ⇒ 补齐官方请求体字段 + 依次认 upload_full_url / upload_param。
测试:media_key_test(6 条,真机形态夹具 + 转义钉住)+ send_media_failure_test(6 条)。
变异 6 条(M-A1/A2/A3/B1/B3a/B3b)均先确认变异体可编译、贴红行、还原复绿。
诚实边界:出站媒体**仍发不出去**(PUT vs 官方 POST、响应头 x-encrypted-param、
nativeMediaItem 嵌套形状三项未做)—— 故意只修到「拿到上传地址」,不扩大切片。
This commit is contained in:
AskaEth committed 2026-10-04 22:33:41 +08:00
1 parent 15181e7063
commit 6cb41a8794
7 files changed
+1047 -52

No files matched your search

+143 -19
View File
@@ -4,13 +4,17 @@ 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"
@@ -19,6 +23,21 @@ import (
// item 类型常量复用 poll.go 里的 iLink item 类型码,使出站媒体与入站解析映射到
// 同一组数字。
const (
// clientIDBytes / fileKeyBytes 是两处随机标识的字节数。取具名常量,是因为它们
// 分别对应协议里的 client_id(去重 ID)与 getuploadurl 的 filekey,不是随手取的数。
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 的响应外壳。
type sendResult struct {
Ret int `json:"ret"`
@@ -86,6 +105,11 @@ func (a *Adapter) sendGuard() error {
// sendToOne 把一条归一化消息作为原生 iLink 消息发给某个对端,用文本加上已上传的
// 媒体组装其 item_list。
//
// **单条媒体的失败绝不连累文本**:媒体只是附件,回复的主体是文字。真机实测
// (2026-10-04)就是「媒体项构建失败 ⇒ 整条回复被判失败 ⇒ 她的中文文本一起没了」。
// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/name/原因),文本项
// 照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复失败。
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
if peer == "" {
return ErrNoContextToken
@@ -99,14 +123,24 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
if m.Content != "" {
items = append(items, nativeTextItem(m.Content))
}
var mediaErr error
for _, md := range m.Media {
item, err := a.buildMediaItem(ctx, md)
item, err := a.buildMediaItem(ctx, peer, md)
if err != nil {
return fmt.Errorf("weixin_ilink: build media item for %q: %w", md.Kind, err)
if mediaErr == nil {
mediaErr = err
}
a.log.Warn("outbound media item skipped",
"peer", peer, "kind", md.Kind, "name", md.Name, "size", md.Size, "err", err)
continue
}
items = append(items, item)
}
if len(items) == 0 {
if mediaErr != nil {
// 一条文本都没有、媒体又全挂:**什么都没发出去**,不能报成功。
return fmt.Errorf("weixin_ilink: send: no deliverable item: %w", mediaErr)
}
return nil
}
return a.sendNative(ctx, peer, ctxToken, items)
@@ -114,7 +148,7 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象及其 AES 密钥的
// 原生 item。
func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, error) {
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)
@@ -132,7 +166,14 @@ func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, er
if err != nil {
return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err)
}
uploadURL, err := a.getUploadURL(ctx)
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)
}
@@ -142,6 +183,19 @@ func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, er
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{
@@ -174,10 +228,29 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
return nil
}
// getUploadURL 申请一条预签名的 CDN 上传 URL。响应字段名在不同服务端版本间有
// 差异,所以会尝试多个候选键。
func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
body, err := json.Marshal(map[string]any{})
// 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)
}
@@ -192,11 +265,27 @@ func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
a.markRejected(rescanReasonUploadURL)
return "", ErrSessionRejected
}
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
if u == "" {
return "", fmt.Errorf("weixin_ilink: getuploadurl returned no url")
if u := strings.TrimSpace(firstString(raw, "upload_full_url")); u != "" {
return u, nil
}
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。
@@ -256,12 +345,47 @@ func nativeMediaTypeFor(kind adapter.MediaKind) int {
}
}
// newClientID 返回随机的十六进制 client id,用于发送去重。若(极小概率的)
// crypto/rand 失败,则退回到基于时间的值。
func newClientID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
// 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
}
return "c-" + strconv.FormatInt(time.Now().UnixNano(), 36)
}
// 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) }
// randomHex 返回 n 字节随机值的十六进制串(2n 个字符)。crypto/rand 失败是极小概率
// 事件,此时退回到基于时间的值 —— 发送路径不该因为随机源故障整条失败。
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err == nil {
return hex.EncodeToString(b)
}
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
}