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

+97 -22
View File
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -225,6 +227,10 @@ func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedM
// 会拼接起来;语音 item 贡献其 ASR 转写;图片/文件/视频 item 先以 CDN 引用的形式
// 挂上(转换期不下载);**文件类**附件的字节随后由 deliver → inlineInboundMedia 在
// 受支持格式与限额内取回并内联(切片 IM-1),图片/视频仍走各自既有链路。
//
// 引用在这里一次性归一成**绝对地址**(`cdnURL`):它同时是媒体取回、来源白名单与
// 日志里的 host 字段所看的形态 —— 留在平台给的相对引用上,故障日志只会写「未知来源」,
// 而绝对地址在取回时仍要过同一道白名单,不会因此放宽。
func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) {
var text strings.Builder
var media []adapter.Media
@@ -249,10 +255,15 @@ func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.Cont
}
content := strings.TrimRight(text.String(), "\n")
if firstMedia != "" {
return content, firstMedia, media
if firstMedia == "" {
return content, adapter.ContentText, nil
}
return content, adapter.ContentText, nil
for i := range media {
if media[i].URL != "" {
media[i].URL = a.cdnURL(media[i].URL)
}
}
return content, firstMedia, media
}
// resolveGroup 在消息携带群标识时返回它,否则返回 ""(私聊)。按 design/08 §4,
@@ -308,40 +319,95 @@ func textOf(m map[string]any, sub string) string {
return ""
}
// mediaOf 从媒体 item 构造 adapter.Media,从平铺 item 或嵌套子对象解析 CDN URL
// 与元数据。
// mediaOf 从媒体 item 构造 adapter.Media。iLink 的媒体引用**正确**位置是
// `<kind>_item.media`(见 cdnMediaOf / mediaRefOf);平铺的 url/cdn_ref 只是早期逆向
// 稿的猜测形态,真机从不出现,但仍保留为兜底。
func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
res := adapter.Media{Kind: kind}
res.URL = firstString(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
res.Name = firstString(m, "file_name", "name")
res.Size = firstInt64(m, "size", "file_size")
res.MIMEType = firstString(m, "mime", "mime_type", "content_type")
if res.URL != "" {
return res
res := adapter.Media{
Kind: kind,
URL: mediaRefOf(m),
Name: firstString(m, "file_name", "name"),
Size: firstInt64(m, "size", "len", "file_size", "video_size"),
MIMEType: firstString(m, "mime", "mime_type", "content_type"),
}
// 对常见的嵌套子对象做有界扫描,寻找 CDN 引用。
for _, sub := range []string{"image_item", "file_item", "video_item", "media_item", "cdn_item"} {
// 对常见的嵌套子对象做有界扫描,补齐平铺层缺失的元数据(引用已由 mediaRefOf
// 覆盖,这里只补名称/体积/类型)。
for _, sub := range mediaSubObjects {
inner, ok := m[sub].(map[string]any)
if !ok {
continue
}
res.URL = firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
if res.Name == "" {
res.Name = firstString(inner, "file_name", "name")
}
if res.Size == 0 {
res.Size = firstInt64(inner, "size", "file_size")
res.Size = firstInt64(inner, "size", "len", "file_size", "video_size")
}
if res.MIMEType == "" {
res.MIMEType = firstString(inner, "mime", "mime_type", "content_type")
}
if res.URL != "" {
return res
}
}
return res
}
// mediaSubObjects 是一个 item 里可能承载媒体载荷的嵌套子对象名。`media` 虽也在此列,
// 但它是 **CDNMedia**(引用与密钥),由 cdnMediaOf/mediaRefOf 专门处理。
var mediaSubObjects = []string{
"image_item", "file_item", "voice_item", "video_item", "media_item", "cdn_item",
}
// cdnMediaOf 返回 item 内嵌的 CDNMedia 子对象(`<kind>_item.media`)。官方协议把真正的
// 下载引用与密钥一并放在这里:`full_url` / `encrypt_query_param` / `aes_key`。
func cdnMediaOf(item map[string]any) map[string]any {
for _, sub := range mediaSubObjects {
inner, ok := item[sub].(map[string]any)
if !ok {
continue
}
if media, ok := inner["media"].(map[string]any); ok {
return media
}
}
if media, ok := item["media"].(map[string]any); ok {
return media
}
return nil
}
// mediaRefOf 解析一个媒体 item 的 CDN 引用,返回**待 cdnURL 归一**的引用串:优先
// `media.full_url`(服务端直接给完整地址),其次把 `media.encrypt_query_param` 折成
// CDN 根下的相对下载引用,最后才是平铺/嵌套的猜测键。
func mediaRefOf(item map[string]any) string {
if media := cdnMediaOf(item); media != nil {
if full := firstString(media, "full_url"); full != "" {
return full
}
if enc := firstString(media, "encrypt_query_param"); enc != "" {
return downloadRef(enc)
}
}
if u := firstString(item, "url", "cdn_url", "download_url", "file_url", "cdn_ref"); u != "" {
return u
}
for _, sub := range mediaSubObjects {
inner, ok := item[sub].(map[string]any)
if !ok {
continue
}
if u := firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref"); u != "" {
return u
}
}
return ""
}
// downloadRef 把 CDN 的加密查询参数折成一个相对下载引用(官方 src/cdn/cdn-url.ts 的
// buildCdnDownloadUrl,只差 CDN 根 —— 根由 cdnURL 拼接)。参数**必须**转义:
// encrypt_query_param 是 base64,其中未转义的 `+` 会被服务端当成空格。
func downloadRef(encryptQueryParam string) string {
return "download?encrypted_query_param=" + url.QueryEscape(encryptQueryParam)
}
// intOf 把 JSON 数字强制转换为 int,缺失或非数字时返回 0。
func intOf(v any) int {
f, ok := v.(float64)
@@ -361,11 +427,20 @@ func firstString(m map[string]any, keys ...string) string {
return ""
}
// firstInt64 返回 keys 中第一个非零整数。
// firstInt64 返回 keys 中第一个非零整数:既接受 JSON 数字,也接受平台偶以**字符串**
// 下发的数值字段(`file_item.len` 就是字符串 `"1234"`)—— 少了后一支,真机上的文件
// 体积会一直是 0,超限预筛就形同虚设。
func firstInt64(m map[string]any, keys ...string) int64 {
for _, k := range keys {
if f, ok := m[k].(float64); ok && f != 0 {
return int64(f)
switch v := m[k].(type) {
case float64:
if v != 0 {
return int64(v)
}
case string:
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n != 0 {
return n
}
}
}
return 0