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

+9
View File
@@ -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 适配器的设置。它由渠道层从环境/配置读取,并可通过
+47 -11
View File
@@ -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),
+156
View File
@@ -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`
// (十六进制原始密钥),其余类型用 `<kind>_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
}
@@ -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):引用与密钥都在 `<kind>_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)
}
}
+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
+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
}
@@ -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 必须记进被拒状态")
}
})
}