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
@@ -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 适配器的设置。它由渠道层从环境/配置读取,并可通过
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 必须记进被拒状态")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user