Files
Cyrene_v2/plugins/channels/weixin_ilink/media.go
T
AskaEth 6cb41a8794
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 嵌套形状三项未做)—— 故意只修到「拿到上传地址」,不扩大切片。
2026-10-04 22:33:41 +08:00

182 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
import (
"context"
"crypto/aes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
// 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 := decodeBase64Flexible(token)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
}
if len(b) < aes.BlockSize {
return nil, ErrKeyTooShort
}
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.botToken())
if err != nil {
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// DownloadMediaWithKey 是 DownloadMedia 的显式按条 AES 密钥版本,用于协议把密钥
// 与媒体引用一并下发的场景。
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
if len(key) != aes.BlockSize {
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// downloadWithKey 拉取 CDN 字节并剥掉 AES-ECB 层。
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
}
plaintext, err := aesECBDecrypt(key, ciphertext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
}
return plaintext, nil
}
// fetchBody 对 rawURL 发 GET 并返回响应体,超过 MaxMediaSize 的一律拒绝,避免单个
// 超大文件耗尽内存。
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: build media request: %w", err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode)
}
limit := a.cfg.MaxMediaSize + 1
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: read media: %w", err)
}
if int64(len(data)) > a.cfg.MaxMediaSize {
return nil, ErrMediaTooLarge
}
return data, nil
}
// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData})
}
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
// 与既有 `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 := f.inboundKey(m)
if err != nil {
return nil, err
}
ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{
URL: f.a.cdnURL(m.URL),
Allow: f.a.assetAllowlist(),
Header: f.a.inboundHeaders(),
Client: f.a.httpClient,
Timeout: mediafetch.DefaultTimeout,
})
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
}
plaintext, err := aesECBDecrypt(key, ciphertext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
}
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),
// 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL)
if err != nil {
return mediafetch.NewAllowlist()
}
return mediafetch.NewAllowlist(origin)
}