fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
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:
1 parent
15181e7063
commit
6cb41a8794
7 files changed
+1047
-52
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user