diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index bccf632..4ed4ce9 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -83,6 +83,15 @@ var ( ErrProactiveWindow = errors.New("weixin_ilink: proactive push outside 24h window") // ErrMediaTooLarge 在下载超过 MaxMediaSize 时返回。 ErrMediaTooLarge = errors.New("weixin_ilink: media exceeds size limit") + // ErrNoMediaKey 在入站附件既没有按条 aes_key、也无法用 bot_token 兜底时返回。 + ErrNoMediaKey = errors.New("weixin_ilink: inbound media carries no aes_key") + // ErrKeyNotAES128 在按条 aes_key 既不是 16 字节原始密钥、也不是 32 位十六进制 + // 密钥串时返回。宁可诚实报错,也不拿长度不对的东西去解密。 + ErrKeyNotAES128 = errors.New("weixin_ilink: aes key is neither 16 raw bytes nor 32 hex chars") + // ErrNoUploadURL 在 getuploadurl 返回成功响应、却既没有完整上传地址也没有可拼接 + // 的上传参数时返回。它与 ErrSessionRejected 是两类失败:前者是协议字段缺失,后者 + // 是凭据被 iLink 拒绝(必须重新扫码)。 + ErrNoUploadURL = errors.New("weixin_ilink: getuploadurl returned no upload url") ) // Config 保存 weixin-ilink 适配器的设置。它由渠道层从环境/配置读取,并可通过 diff --git a/plugins/channels/weixin_ilink/media.go b/plugins/channels/weixin_ilink/media.go index 7981ceb..cd9056a 100644 --- a/plugins/channels/weixin_ilink/media.go +++ b/plugins/channels/weixin_ilink/media.go @@ -3,7 +3,6 @@ package weixin_ilink import ( "context" "crypto/aes" - "encoding/base64" "errors" "fmt" "io" @@ -17,15 +16,22 @@ import ( // ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。 var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block") -// deriveInboundKey 为入站媒体负载推导 AES-128 密钥。按 design/08 §3.3,密钥从 -// bot_token 中提取:先 base64 解码 token,再取末尾 16 字节。参考实现把这一步标记为 -// ⚠️,因此 DownloadMediaWithKey 允许调用方在平台按每条媒体下发密钥时显式传入密钥。 +// deriveInboundKey 用 bot_token 推导 AES-128 密钥:base64 解码 token,取末尾 16 字节。 +// +// ⚠️ 这是**兜底**,不是主路径。真机核对的结论见 media_key.go 的文件头:官方包从不 +// 从 bot_token 推导媒体密钥(密钥按条随附件下发),而真实 bot_token 形如 +// `<12 位>@im.bot:<38 位十六进制>`、根本不是 base64 —— 对它调用本函数**必然**失败 +// (第 12 个字符 `@` 处非法)。保留它只为「token 确实以 base64 下发」的部署与 +// `DownloadMedia` 的既有契约。 +// +// 解码容忍 URL-safe 与无 padding 的 base64 变体:凭据由第三方下发,形态不由我们决定, +// 先猜一种再报「illegal base64」正是真机踩过的那个坑。 func deriveInboundKey(botToken string) ([]byte, error) { token := strings.TrimSpace(botToken) if token == "" { return nil, ErrInvalidConfig } - b, err := base64.StdEncoding.DecodeString(token) + b, err := decodeBase64Flexible(token) if err != nil { return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err) } @@ -35,10 +41,20 @@ func deriveInboundKey(botToken string) ([]byte, error) { return b[len(b)-aes.BlockSize:], nil } +// botToken 返回已配置的 bot token 快照(凭据只在锁内读,绝不进日志)。 +func (a *Adapter) botToken() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.cfg.BotToken +} + // DownloadMedia 下载一条入站 CDN 引用,并用从已配置 bot_token 推导出的密钥返回 // 明文。密文超过 MaxMediaSize 时以 ErrMediaTooLarge 失败。 +// +// 只有 URL、没有原始消息载荷的调用方走这条路径(按条密钥无从查起);轮询投递出来的 +// 附件走 ilinkFetcher,那条路径优先用平台随附件下发的密钥。 func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) { - key, err := deriveInboundKey(a.cfg.BotToken) + key, err := deriveInboundKey(a.botToken()) if err != nil { return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err) } @@ -98,22 +114,28 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) // 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行 // 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { - mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a}) + mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData}) } // ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对 // URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 —— -// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单与独立的体积上限。 -type ilinkFetcher struct{ a *Adapter } +// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、独立的体积上限,以及 +// **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。 +type ilinkFetcher struct { + a *Adapter + // raw 是这条消息的原始 iLink 载荷。按条 AES 密钥随附件下发,而归一化契约里没有 + // 承载它的字段,因此密钥按需从这里回查(见 inboundMediaKey)。 + raw []byte +} // Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由 // mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量 // `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限, // 与本切片的文档上限刻意解耦)。 func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { - key, err := deriveInboundKey(f.a.cfg.BotToken) + key, err := f.inboundKey(m) if err != nil { - return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err) + return nil, err } ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{ URL: f.a.cdnURL(m.URL), @@ -132,6 +154,20 @@ func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error return plaintext, nil } +// inboundKey 决定本次解密用哪个密钥:**优先平台随附件下发的按条 aes_key**(官方包的 +// 入站口径),只有这条消息确实没带密钥时才退回 bot_token 推导的兜底。两条都拿不到时 +// 诚实报错 —— 绝不拿一个来路不明的密钥去解,那只会解出乱码。 +func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) { + if key, ok := f.a.inboundMediaKey(f.raw, m.URL); ok { + return key, nil + } + key, err := deriveInboundKey(f.a.botToken()) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: no per-item aes_key and bot_token fallback: %w", err) + } + return key, nil +} + // assetAllowlist 给出入站附件取回的来源白名单:**只有已配置的 CDN 根**(默认 // `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用 // 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute), diff --git a/plugins/channels/weixin_ilink/media_key.go b/plugins/channels/weixin_ilink/media_key.go new file mode 100644 index 0000000..e418af1 --- /dev/null +++ b/plugins/channels/weixin_ilink/media_key.go @@ -0,0 +1,156 @@ +package weixin_ilink + +// 本文件承载微信 iLink 的**入站解密密钥来源**:它从 media.go 拆出,因为「密钥从哪来」 +// 与「字节怎么取回来」是两件事,而前者在真机上踩过坑(2026-10-04 真机:入站 `.txt` +// 的取回以 +// +// derive inbound key: decode bot_token for key: illegal base64 data at input byte 12 +// +// 失败)。 +// +// 权威口径取自官方包 @tencent-weixin/openclaw-weixin 2.4.9 的 src/(2026-10-04 拉取 +// 核对,AGENTS §9.5): +// - src/media/media-download.ts:**每条附件自带平台下发的 AES-128 密钥** —— 图片取 +// `image_item.aeskey`(十六进制,优先),语音/文件/视频取 `*_item.media.aes_key`; +// - src/cdn/pic-decrypt.ts 的 parseAesKey:`aes_key` 有两种线上编码,base64(16 字节 +// 原始密钥) 与 base64(32 位十六进制串); +// - 整个包**没有任何**「从 bot_token 推导媒体密钥」的代码。 +// +// 设计文档 design/08 §3.3 记的「bot_token base64 解码取后 16 字节」来源单一且自标 ⚠️, +// 与真机不符:真实 bot_token 形如 `<12 位>@im.bot:<38 位十六进制>`(官方 docs 02 的 +// confirmed 响应示例 `44b9b8ec@im.bot:060000b7...` 同形),整体**不是** base64, +// 于是旧实现在第 12 个字符(`@`)处必然失败。因此本包的口径是: +// **按条密钥是主路径,deriveInboundKey 只作兜底**。 + +import ( + "crypto/aes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// base64Variants 是解码平台字段时依次尝试的 base64 变体。真实 token/凭据既可能是 +// 标准带 padding 的 base64,也可能是 URL-safe(`-`/`_`)或无 padding 的形态 —— 它们 +// 互为子集但不互相兼容,所以逐一尝试,而不是先猜一个再报「illegal base64」。 +// 顺序有意义:先标准、再「无 padding 标准」,最后 URL-safe 两式。 +var base64Variants = []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, +} + +// decodeBase64Flexible 解码一个平台下发的 base64 字段,容忍 URL-safe 与无 padding 变体。 +func decodeBase64Flexible(s string) ([]byte, error) { + token := strings.TrimSpace(s) + if token == "" { + return nil, fmt.Errorf("weixin_ilink: empty base64 payload") + } + var firstErr error + for _, enc := range base64Variants { + decoded, err := enc.DecodeString(token) + if err == nil { + return decoded, nil + } + if firstErr == nil { + firstErr = err + } + } + return nil, fmt.Errorf("weixin_ilink: decode base64 (standard/url-safe, padded/raw): %w", firstErr) +} + +// parseMediaAesKey 把一个平台下发的 `aes_key` 解析成 16 字节 AES-128 密钥。两种线上 +// 编码都要认(官方 src/cdn/pic-decrypt.ts 的 parseAesKey):先 base64 解码,得到 16 +// 字节就是原始密钥;得到 32 个 ASCII 十六进制字符则是「密钥的十六进制串」,再解一层 +// 十六进制。少了第二分支,文件/语音/视频会永远以「密钥长度不对」解不开。 +func parseMediaAesKey(encoded string) ([]byte, error) { + raw, err := decodeBase64Flexible(encoded) + if err != nil { + return nil, fmt.Errorf("weixin_ilink: decode media aes_key: %w", err) + } + if len(raw) == aes.BlockSize { + return raw, nil + } + if len(raw) == hex.EncodedLen(aes.BlockSize) && isHexASCII(raw) { + key := make([]byte, aes.BlockSize) + if _, err := hex.Decode(key, raw); err != nil { + return nil, fmt.Errorf("weixin_ilink: decode 32-char hex media aes_key: %w", err) + } + return key, nil + } + return nil, fmt.Errorf("%w: got %d decoded bytes", ErrKeyNotAES128, len(raw)) +} + +// isHexASCII 报告 b 是否全为 ASCII 十六进制字符。 +func isHexASCII(b []byte) bool { + for _, c := range b { + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} + +// mediaAesKeyOf 返回一个媒体 item 自带的按条 AES 密钥。图片优先用 `image_item.aeskey` +// (十六进制原始密钥),其余类型用 `_item.media.aes_key`(base64)—— 与官方 +// src/media/media-download.ts 的分支一一对应。两者都没有时返回 ErrNoMediaKey。 +func mediaAesKeyOf(item map[string]any) ([]byte, error) { + if intOf(item["type"]) == itemImage { + if inner, ok := item["image_item"].(map[string]any); ok { + if raw := firstString(inner, "aeskey"); raw != "" { + if key, err := hex.DecodeString(strings.TrimSpace(raw)); err == nil && len(key) == aes.BlockSize { + return key, nil + } + // 十六进制解不出 16 字节就落回 media.aes_key:宁可换一条已证实的路径, + // 也不用一个被静默截断的密钥去解密(官方对非法 hex 是静默截断)。 + } + } + } + media := cdnMediaOf(item) + if media == nil { + return nil, ErrNoMediaKey + } + encoded := firstString(media, "aes_key") + if encoded == "" { + return nil, ErrNoMediaKey + } + return parseMediaAesKey(encoded) +} + +// inboundMediaKey 在**原始消息载荷**里找出与 resolvedURL 对应的按条 AES 密钥。 +// +// 为什么要回原始载荷找:归一化契约 `adapter.UnifiedMessage.Media` 没有承载密钥的字段, +// 而密钥又是**按条**下发的。把密钥塞进 `Media.URL` 或适配器级缓存都会让它有机会流进 +// 日志或跨消息串味;改为按需回读这条消息自己的原始 JSON,就既没有共享状态、也没有 +// 生命周期问题 —— 该函数只在真的要取某一条附件时才被调用。 +// +// 找不到(载荷缺失、引用对不上、没有 aes_key)时返回 ok=false,由调用方决定兜底。 +func (a *Adapter) inboundMediaKey(raw []byte, resolvedURL string) ([]byte, bool) { + if len(raw) == 0 || resolvedURL == "" { + return nil, false + } + var msg struct { + ItemList []json.RawMessage `json:"item_list"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + return nil, false + } + for _, r := range msg.ItemList { + var item map[string]any + if err := json.Unmarshal(r, &item); err != nil { + continue + } + ref := mediaRefOf(item) + if ref == "" || a.cdnURL(ref) != resolvedURL { + continue + } + if key, err := mediaAesKeyOf(item); err == nil { + return key, true + } + } + return nil, false +} diff --git a/plugins/channels/weixin_ilink/media_key_test.go b/plugins/channels/weixin_ilink/media_key_test.go new file mode 100644 index 0000000..b08b2ea --- /dev/null +++ b/plugins/channels/weixin_ilink/media_key_test.go @@ -0,0 +1,265 @@ +package weixin_ilink + +// 本文件锁定微信 iLink 入站附件的**密钥来源与真实字段形态**(2026-10-04 真机缺陷)。 +// +// 真机形态(官方 @tencent-weixin/openclaw-weixin 2.4.9 的 src/api/types.ts 与 +// src/media/media-download.ts):引用与密钥都在 `_item.media` 里 —— +// `encrypt_query_param`(或 `full_url`)给地址、`aes_key` 给密钥(图片优先 +// `image_item.aeskey`);文件名在 `file_name`,体积在**字符串** `len`。 +// +// 旧实现只认平铺的 `file_item.url`,且把**整个 bot_token** 当 base64 去解(自标 ⚠️ 的 +// design/08 §3.3 口径)——真实 token 形如 `<12 位>@im.bot:<38 位十六进制>`,在第 12 个 +// 字符 `@` 处必失败。下面的夹具因此刻意用「同形但不是真凭据」的 token,让「附件能不能 +// 读出来」只取决于按条 aes_key 是否被用上。 + +import ( + "bytes" + "context" + "crypto/aes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// realCdnServer 是假 iLink CDN:记录命中次数与服务端**解码后**的 encrypt_query_param, +// 并按需下发字节。它比共享夹具多记一个查询参数,用来钉住引用必须转义。 +type realCdnServer struct { + srv *httptest.Server + hits atomic.Int32 + query atomic.Value + body []byte +} + +func newRealCdnServer(t *testing.T, body []byte) *realCdnServer { + t.Helper() + s := &realCdnServer{body: body} + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.hits.Add(1) + s.query.Store(r.URL.Query().Get("encrypted_query_param")) + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(s.body) + })) + t.Cleanup(s.srv.Close) + return s +} + +// seenQuery 返回服务端解码后的 encrypt_query_param。 +func (s *realCdnServer) seenQuery() string { + v, _ := s.query.Load().(string) + return v +} + +// realShapeBotToken 返回一个**与真机同形**的 bot_token:`<12 位>@im.bot:<38 位十六进制>`。 +// 它不是任何真实凭据,只是形状 —— 但足以让「整体 base64 解码 token」这条路必失败。 +func realShapeBotToken() string { + return "0123456789ab@im.bot:" + strings.Repeat("0f", 19) +} + +// realFileItemRaw 构造一条真机形态的文件消息。 +func realFileItemRaw(encParam, aesKeyB64, name, size string) []byte { + item := map[string]any{ + "type": itemFile, + "file_item": map[string]any{ + "media": map[string]any{"encrypt_query_param": encParam, "aes_key": aesKeyB64}, + "file_name": name, + "md5": "d41d8cd98f00b204e9800998ecf8427e", + "len": size, + }, + } + return wrapItem(item) +} + +// realImageItemRaw 构造一条真机形态的图片消息:十六进制密钥在 `image_item.aeskey`, +// 这里让 `media.aes_key` 带一个**错误**密钥(base64 编码),只有走对优先分支才解得开。 +func realImageItemRaw(encParam, aeskeyHex, wrongKeyB64 string) []byte { + item := map[string]any{ + "type": itemImage, + "image_item": map[string]any{ + "media": map[string]any{"encrypt_query_param": encParam, "aes_key": wrongKeyB64}, + "aeskey": aeskeyHex, + }, + } + return wrapItem(item) +} + +// wrapItem 把单个 item 包成一条入站用户消息。 +func wrapItem(item map[string]any) []byte { + raw, err := json.Marshal(map[string]any{ + "client_id": "c-1", "from_user_id": "u-1", "message_type": msgTypeUser, + "item_list": []any{item}, + }) + if err != nil { + panic(err.Error()) + } + return raw +} + +// newRealCdnAdapter 装配一个「CDN 根指向假服务端、bot_token 为真机形态」的适配器与捕获器。 +func newRealCdnAdapter(t *testing.T, s *realCdnServer) (*Adapter, *ilinkInlineCapture) { + t.Helper() + cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()} + a := newAdapter(t, cfg, s.srv) + c := &ilinkInlineCapture{} + a.SetMessageHandler(c.fn) + return a, c +} + +// TestInboundRealFileItemIsDecryptedWithPerItemKey 是 **Bug A 的回归用例**:真机形态的 +// 文件 item(引用在 media.encrypt_query_param、密钥在 media.aes_key)+ 真机形态的 +// bot_token(不是 base64)。修好之前,取回在 deriveInboundKey 上就失败,字节为空。 +func TestInboundRealFileItemIsDecryptedWithPerItemKey(t *testing.T) { + t.Parallel() + key := keyOf(0x41) + plain := []byte("真机形态的文件正文\n") + // 查询参数里带 `+`/`/`/`=`:不转义的话服务端会把 `+` 解成空格,签名参数就废了。 + encParam := "ab+cd/ef=" + s := newRealCdnServer(t, encrypted(t, key, plain)) + a, c := newRealCdnAdapter(t, s) + + _, m := a.convertMessage(realFileItemRaw(encParam, base64.StdEncoding.EncodeToString(key), "1.txt", "24")) + if m == nil { + t.Fatal("convertMessage 没有解析出消息") + } + a.deliver(context.Background(), *m, nil) + + msg := c.message(t) + if len(msg.Media) != 1 { + t.Fatalf("Media = %d 条, want 1", len(msg.Media)) + } + got := msg.Media[0] + if !bytes.Equal(got.Data, plain) { + t.Fatalf("Media.Data = %q, want %q", got.Data, plain) + } + if got.Name != "1.txt" { + t.Fatalf("Name = %q, want 1.txt", got.Name) + } + if got.Size != 24 { + t.Fatalf("Size = %d, want 24(file_item.len 是字符串,必须能解析)", got.Size) + } + if !strings.Contains(got.URL, "/download?encrypted_query_param=") { + t.Fatalf("URL = %q, want 归一后的 CDN 下载地址", got.URL) + } + if s.hits.Load() != 1 { + t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load()) + } + if s.seenQuery() != encParam { + t.Fatalf("服务端收到的 encrypt_query_param = %q, want %q(引用必须转义)", s.seenQuery(), encParam) + } +} + +// TestInboundRealFileItemAcceptsBase64HexAesKey 覆盖官方 parseAesKey 的第二种编码: +// 文件/语音/视频的 aes_key 是 **base64(32 位十六进制串)**,要先 base64 再解一层十六进制。 +func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) { + t.Parallel() + key := keyOf(0x44) + plain := []byte("十六进制密钥包装形态\n") + s := newRealCdnServer(t, encrypted(t, key, plain)) + a, c := newRealCdnAdapter(t, s) + + wrapped := base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(key))) + _, m := a.convertMessage(realFileItemRaw("only-param", wrapped, "report.txt", "1024")) + a.deliver(context.Background(), *m, nil) + + msg := c.message(t) + if !bytes.Equal(msg.Media[0].Data, plain) { + t.Fatalf("Media.Data = %q, want %q", msg.Media[0].Data, plain) + } +} + +// TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey` +// (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有 +// 走对分支才解得出正文。图片不进内联链路(mediafetch 只管文件类),所以直接驱动 Fetcher。 +func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) { + t.Parallel() + right := keyOf(0x42) + wrong := keyOf(0x99) + plain := []byte("jpeg-bytes\n") + s := newRealCdnServer(t, encrypted(t, right, plain)) + cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()} + a := newAdapter(t, cfg, s.srv) + + raw := realImageItemRaw("img+param=", hex.EncodeToString(right), base64.StdEncoding.EncodeToString(wrong)) + _, m := a.convertMessage(raw) + if m == nil || len(m.Media) != 1 { + t.Fatalf("convertMessage 没有解析出图片媒体:%+v", m) + } + got, err := ilinkFetcher{a: a, raw: raw}.Fetch(context.Background(), m.Media[0]) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if !bytes.Equal(got, plain) { + t.Fatalf("解出的字节 = %q, want %q", got, plain) + } +} + +// TestInboundFileItemWithoutAesKeyIsHonest 钉住诚实失败:附件没带密钥时,宁可报错让 +// 上游给模型一行「读不到」,也绝不用 bot_token 兜底去解出一堆乱码 —— 而且连请求都不该 +// 发出去(密钥都不齐,取回来也只会浪费一次传输)。 +func TestInboundFileItemWithoutAesKeyIsHonest(t *testing.T) { + t.Parallel() + key := keyOf(0x45) + s := newRealCdnServer(t, encrypted(t, key, []byte("unreachable"))) + a, c := newRealCdnAdapter(t, s) + + _, m := a.convertMessage(realFileItemRaw("no-key-param", "", "1.txt", "3")) + a.deliver(context.Background(), *m, nil) + + msg := c.message(t) + if len(msg.Media[0].Data) != 0 { + t.Fatalf("没有密钥却写入了字节:%q", msg.Media[0].Data) + } + if s.hits.Load() != 0 { + t.Fatalf("密钥不齐仍发起了 %d 次请求, want 0", s.hits.Load()) + } + if msg.SenderID != "u-1" { + t.Fatalf("失败路径把消息弄丢了:%q", msg.SenderID) + } +} + +// TestDeriveInboundKey_AcceptsRawAndURLSafeBase64 钉住兜底路径的编码容忍度:凭据由 +// 第三方下发,无 padding 与 URL-safe(`-`/`_`)两种形态都要认 —— 先猜一种再报 +// 「illegal base64」正是真机踩过的坑。 +func TestDeriveInboundKey_AcceptsRawAndURLSafeBase64(t *testing.T) { + t.Parallel() + key := keyOf(0xFF) + payload := append([]byte("prefix!"), key...) + std := base64.StdEncoding.EncodeToString(payload) + urlSafe := strings.NewReplacer("+", "-", "/", "_", "=", "").Replace(std) + if !strings.ContainsAny(urlSafe, "-_") || urlSafe == std { + t.Fatalf("夹具没有造出 URL-safe 无 padding 形态:%q", std) + } + + for name, token := range map[string]string{ + "url-safe raw": urlSafe, + "standard raw": strings.TrimRight(std, "="), + "standard std": std, + } { + got, err := deriveInboundKey(token) + if err != nil { + t.Fatalf("%s: deriveInboundKey: %v", name, err) + } + if !bytes.Equal(got, key) { + t.Fatalf("%s: key = %x, want %x", name, got, key) + } + } +} + +// TestParseMediaAesKey_RejectsWrongLength 钉住密钥长度校验:长度不对时诚实报错, +// 而不是拿被截断/补零的东西去解密。 +func TestParseMediaAesKey_RejectsWrongLength(t *testing.T) { + t.Parallel() + if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 8))); err == nil { + t.Fatal("8 字节的 aes_key 被接受了") + } + if _, err := parseMediaAesKey(""); err == nil { + t.Fatal("空的 aes_key 被接受了") + } + if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, aes.BlockSize))); err != nil { + t.Fatalf("16 字节原始密钥被拒:%v", err) + } +} diff --git a/plugins/channels/weixin_ilink/poll.go b/plugins/channels/weixin_ilink/poll.go index a97e903..1150faa 100644 --- a/plugins/channels/weixin_ilink/poll.go +++ b/plugins/channels/weixin_ilink/poll.go @@ -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 的媒体引用**正确**位置是 +// `_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 子对象(`_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 diff --git a/plugins/channels/weixin_ilink/send.go b/plugins/channels/weixin_ilink/send.go index e33d691..011e5a5 100644 --- a/plugins/channels/weixin_ilink/send.go +++ b/plugins/channels/weixin_ilink/send.go @@ -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 } diff --git a/plugins/channels/weixin_ilink/send_media_failure_test.go b/plugins/channels/weixin_ilink/send_media_failure_test.go new file mode 100644 index 0000000..6402823 --- /dev/null +++ b/plugins/channels/weixin_ilink/send_media_failure_test.go @@ -0,0 +1,330 @@ +package weixin_ilink + +// 本文件锁定出站媒体的两条真机纪律(2026-10-04 实机缺陷): +// +// B1 单条媒体项构建/上传失败**不得**把整条回复(含文本)判失败 —— +// 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去; +// B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」 +// 与「会话被拒」必须分开报(后者已有 markRejected 路径)。 + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// mediaSendServer 是假 iLink:getuploadurl 按 upload 作答,上传成功,sendmessage 回 ret:0。 +func mediaSendServer(t *testing.T, rec *reqRecorder, upload handlerResult) *httptest.Server { + t.Helper() + return newJSONServer(t, rec, func(req recordedReq) handlerResult { + switch { + case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": + return upload + case req.method == http.MethodPut: + return handlerResult{status: http.StatusOK, body: []byte("ok")} + case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage": + return handlerResult{body: jsonBody(map[string]any{"ret": 0})} + default: + return handlerResult{status: http.StatusNotFound} + } + }) +} + +// newLoggingSendAdapter 装配一个把日志写进 buf 的已连接适配器,用来断言失败**不静默**。 +func newLoggingSendAdapter(t *testing.T, srv *httptest.Server, buf *bytes.Buffer) *Adapter { + t.Helper() + log := logger.WithSource( + slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), source) + ad, err := New(Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv.Client(), log) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := ad.Connect(context.Background()); err != nil { + t.Fatalf("Connect: %v", err) + } + return ad +} + +// outboundItems 返回第一个 sendmessage 请求的 item_list。 +func outboundItems(t *testing.T, rec *reqRecorder) []any { + t.Helper() + items, ok := getSendBody(t, rec)["msg"].(map[string]any)["item_list"].([]any) + if !ok { + t.Fatal("sendmessage 没有 item_list") + } + return items +} + +// TestSendMessage_MediaFailureStillSendsText 是 **Bug B1 的回归用例**:媒体项构建失败时, +// 文本必须照常发出,且失败要留下带来源的 WARN。 +func TestSendMessage_MediaFailureStillSendsText(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + // getuploadurl 回一个「什么都没有」的空壳:媒体项必然构建失败。 + srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})}) + var buf bytes.Buffer + ad := newLoggingSendAdapter(t, srv, &buf) + ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + const reply = "这是一条正常的中文回复" + err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Content: reply, + Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}}, + }}, + }) + if err != nil { + t.Fatalf("媒体失败连累了整条回复:%v", err) + } + + items := outboundItems(t, rec) + if len(items) != 1 { + t.Fatalf("item_list = %d 条, want 1(失败的媒体被跳过、文本照发)", len(items)) + } + item, _ := items[0].(map[string]any) + if intOf(item["type"]) != itemText { + t.Fatalf("item type = %v, want %d", item["type"], itemText) + } + if txt := item["text_item"].(map[string]any)["text"]; txt != reply { + t.Fatalf("发出的文本 = %q, want %q", txt, reply) + } + + // 失败必须可诊断:一条带来源、带原因的 WARN。 + logs := buf.String() + if !strings.Contains(logs, "level=WARN") || !strings.Contains(logs, "outbound media item skipped") { + t.Fatalf("媒体失败没有留下 WARN:\n%s", logs) + } + if !strings.Contains(logs, "source="+source) { + t.Fatalf("告警没有标注来源:\n%s", logs) + } + if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "name=1.txt") { + t.Fatalf("告警缺少 kind/name:\n%s", logs) + } +} + +// TestSendMessage_AllMediaFailWithoutTextIsReported 钉住反向边界:一条文本都没有、媒体 +// 又全挂 ⇒ **什么都没发出去**,不能报成功(诚实失败,而不是静默丢弃)。 +func TestSendMessage_AllMediaFailWithoutTextIsReported(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})}) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}}, + }}, + }) + if !errors.Is(err, ErrNoUploadURL) { + t.Fatalf("err = %v, want ErrNoUploadURL", err) + } + if reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/sendmessage" }); len(reqs) != 0 { + t.Fatalf("没有任何可投递内容却发出了 %d 次 sendmessage", len(reqs)) + } +} + +// TestGetUploadURL_RequestCarriesProtocolFields 钉住「returned no url」的真根因:旧实现 +// 发的是**空 body**,而官方 GetUploadUrlReq 要求 +// filekey/media_type/to_user_id/rawsize/rawfilemd5/filesize/no_need_thumb/aeskey。 +// 没有这些字段,服务端只会回一个空壳。 +func TestGetUploadURL_RequestCarriesProtocolFields(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})}) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + payload := []byte("fake-jpeg-bytes") + // 媒体仍会按 B1 被跳过(这里不关心结果),我们只看它发出的 getuploadurl 请求。 + _ = ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Media: []adapter.Media{{Kind: adapter.MediaImage, Data: payload, Name: "a.jpg"}}, + }}, + }) + + reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" }) + if len(reqs) != 1 { + t.Fatalf("getuploadurl 请求 = %d 次, want 1", len(reqs)) + } + body := reqs[0].bodyJSON() + if body == nil { + t.Fatal("getuploadurl 请求体不是 JSON") + } + fileKey, _ := body["filekey"].(string) + if len(fileKey) != 2*fileKeyBytes || !isHexASCII([]byte(fileKey)) { + t.Fatalf("filekey = %q, want %d 位十六进制", fileKey, 2*fileKeyBytes) + } + // ⚠️ 这里必须是 getuploadurl 的 media_type 码(图片=1),而不是 item 的 type 码(图片=2)。 + if got := intOf(body["media_type"]); got != uploadMediaImage { + t.Fatalf("media_type = %v, want %d(与 item 码不是一套)", body["media_type"], uploadMediaImage) + } + if body["to_user_id"] != "u-1" { + t.Fatalf("to_user_id = %v, want u-1", body["to_user_id"]) + } + if got := int64(intOf(body["rawsize"])); got != int64(len(payload)) { + t.Fatalf("rawsize = %d, want %d", got, len(payload)) + } + if body["rawfilemd5"] != md5Hex(payload) { + t.Fatalf("rawfilemd5 = %v, want %s", body["rawfilemd5"], md5Hex(payload)) + } + cipherSize := int64(intOf(body["filesize"])) + if cipherSize <= int64(len(payload)) || cipherSize%16 != 0 { + t.Fatalf("filesize = %d, want PKCS#7 填充后的密文长度(> %d 且为 16 的倍数)", cipherSize, len(payload)) + } + if body["no_need_thumb"] != true { + t.Fatalf("no_need_thumb = %v, want true(我们不上传缩略图)", body["no_need_thumb"]) + } + // aeskey 必须是真正用于加密的那把:拿它去解上传的密文能还原原文。 + rawKey, err := hex.DecodeString(body["aeskey"].(string)) + if err != nil { + t.Fatalf("aeskey 不是十六进制:%v", err) + } + puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }) + if len(puts) != 0 { + t.Fatalf("上传地址缺失时不该发起上传:%d 次", len(puts)) + } + if len(rawKey) != 16 { + t.Fatalf("aeskey = %d 字节, want 16", len(rawKey)) + } +} + +// TestOutboundMedia_UsesUploadFullURL 覆盖官方 GetUploadUrlResp 的主字段 +// `upload_full_url`:旧实现只认 upload_url/url/... 这几个猜测名,拿到它也会判「没地址」。 +func TestOutboundMedia_UsesUploadFullURL(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + var srv *httptest.Server + srv = newJSONServer(t, rec, func(req recordedReq) handlerResult { + switch { + case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": + return handlerResult{body: jsonBody(map[string]any{"upload_full_url": srv.URL + "/cdn/up"})} + case req.method == http.MethodPut && req.path == "/cdn/up": + return handlerResult{status: http.StatusOK, body: []byte("ok")} + case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage": + return handlerResult{body: jsonBody(map[string]any{"ret": 0})} + default: + return handlerResult{status: http.StatusNotFound} + } + }) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}}, + }}, + }); err != nil { + t.Fatalf("SendMessage(media, upload_full_url): %v", err) + } + items := outboundItems(t, rec) + if len(items) != 1 || intOf(items[0].(map[string]any)["type"]) != itemImage { + t.Fatalf("item_list = %+v, want 一条图片 item", items) + } +} + +// TestOutboundMedia_BuildsUploadURLFromUploadParam 覆盖 `upload_param`:官方口径是把它 +// 拼到 CDN 根下的 `/upload?encrypted_query_param=…&filekey=…`,且 filekey 必须与 +// getuploadurl 请求里发出的那一个一致。 +func TestOutboundMedia_BuildsUploadURLFromUploadParam(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + var srv *httptest.Server + srv = newJSONServer(t, rec, func(req recordedReq) handlerResult { + switch { + case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": + return handlerResult{body: jsonBody(map[string]any{"upload_param": "ab+cd="})} + case req.method == http.MethodPut && req.path == "/upload": + return handlerResult{status: http.StatusOK, body: []byte("ok")} + case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage": + return handlerResult{body: jsonBody(map[string]any{"ret": 0})} + default: + return handlerResult{status: http.StatusNotFound} + } + }) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}}, + }}, + }); err != nil { + t.Fatalf("SendMessage(media, upload_param): %v", err) + } + + puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }) + if len(puts) != 1 { + t.Fatalf("上传请求 = %d 次, want 1", len(puts)) + } + if !strings.Contains(puts[0].query, "encrypted_query_param=ab%2Bcd%3D") { + t.Fatalf("上传查询串 = %q, want 转义后的 encrypted_query_param", puts[0].query) + } + param, err := url.ParseQuery(puts[0].query) + if err != nil { + t.Fatalf("上传查询串不合法:%v", err) + } + if got := param.Get("encrypted_query_param"); got != "ab+cd=" { + t.Fatalf("服务端解出的 upload_param = %q, want ab+cd=", got) + } + upReqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" }) + body := upReqs[0].bodyJSON() + if got, want := param.Get("filekey"), body["filekey"]; got != want { + t.Fatalf("上传 URL 里的 filekey = %q, 与 getuploadurl 请求里的 %v 不一致", got, want) + } +} + +// TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected 钉住两类失败的分野: +// 键缺失是协议字段对不上(ErrNoUploadURL,不改变凭据状态),errcode=-14 是凭据被拒 +// (ErrSessionRejected,必须记进被拒状态、要求重新扫码)。 +func TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected(t *testing.T) { + t.Parallel() + req := uploadRequest{peer: "u-1", mediaType: uploadMediaFile, aesKey: make([]byte, 16)} + + t.Run("address missing", func(t *testing.T) { + t.Parallel() + srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{})}) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + _, err := ad.getUploadURL(context.Background(), req) + if !errors.Is(err, ErrNoUploadURL) { + t.Fatalf("err = %v, want ErrNoUploadURL", err) + } + if errors.Is(err, ErrSessionRejected) { + t.Fatal("键缺失被误判成凭据被拒") + } + if required, _ := ad.RescanRequired(); required { + t.Fatal("键缺失不该要求重新扫码") + } + }) + + t.Run("session rejected", func(t *testing.T) { + t.Parallel() + srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}) + ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) + _, err := ad.getUploadURL(context.Background(), req) + if !errors.Is(err, ErrSessionRejected) { + t.Fatalf("err = %v, want ErrSessionRejected", err) + } + if errors.Is(err, ErrNoUploadURL) { + t.Fatal("凭据被拒被降级成「没返回地址」") + } + if required, _ := ad.RescanRequired(); !required { + t.Fatal("errcode=-14 必须记进被拒状态") + } + }) +}