Files
Cyrene_v2/plugins/channels/weixin_ilink/send.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

392 lines
14 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 (
"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"
)
// 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"`
Errcode int `json:"errcode"`
}
// SendMessage 向渠道投递一条回复。每条消息都发给从该消息(或 resp.ReplyTo)
// 解析出的对端,并使用该对端已存的 context_token,以便平台把回复关联到正确的
// 会话(design/08 §8)。
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if err := a.sendGuard(); err != nil {
return err
}
for _, m := range resp.Messages {
peer, _ := recipient(m)
if peer == "" {
peer = resp.ReplyTo
}
if err := a.sendToOne(ctx, peer, m); err != nil {
return fmt.Errorf("weixin_ilink: send message: %w", err)
}
}
return nil
}
// SendProactive 推送一条并非由近期入站消息触发的消息。微信 iLink 只允许在用户
// 最后一条消息之后的 24 小时窗口内这么做(design/08 §3-4):用户沉默更久、或从未
// 发过消息时,推送以 ErrProactiveWindow 被拒,而 Capabilities.Proactive 仅为 true,
// 好让调用方知道该能力存在。
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := a.sendGuard(); err != nil {
return err
}
peer, _ := recipient(msg)
if peer == "" {
return ErrNoContextToken
}
a.mu.RLock()
last, seen := a.lastMsgAt[peer]
window := a.cfg.ProactiveWindow
a.mu.RUnlock()
if !seen || a.clock().Sub(last) > window {
return ErrProactiveWindow
}
if a.contextFor(peer) == "" {
return ErrNoContextToken
}
return a.sendToOne(ctx, peer, msg)
}
// sendGuard 报告发送在触网之前必须暴露的错误:iLink 已拒绝的会话(errcode=-14)
// 表现为 ErrSessionRejected——主人必须重新扫码——而其余任何断开状态仍是
// ErrNotConnected。
func (a *Adapter) sendGuard() error {
if rejected, reason := a.rejectedState(); rejected {
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason)
}
if !a.IsConnected() {
return adapter.ErrNotConnected
}
return nil
}
// 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
}
ctxToken := a.contextFor(peer)
if ctxToken == "" {
return ErrNoContextToken
}
items := make([]any, 0, 1+len(m.Media))
if m.Content != "" {
items = append(items, nativeTextItem(m.Content))
}
var mediaErr error
for _, md := range m.Media {
item, err := a.buildMediaItem(ctx, peer, md)
if err != nil {
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)
}
// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象及其 AES 密钥的
// 原生 item。
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)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media source: %w", err)
}
plaintext = data
}
key := make([]byte, aes.BlockSize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("weixin_ilink: generate aes key: %w", err)
}
encrypted, err := aesECBEncrypt(key, plaintext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err)
}
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)
}
if err := a.putBytes(ctx, uploadURL, encrypted); err != nil {
return nil, fmt.Errorf("weixin_ilink: upload media: %w", err)
}
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{
"msg": map[string]any{
"to_user_id": peer,
"client_id": newClientID(),
"message_type": 2, // 机器人消息
"message_state": 2, // FINISH(结束态)
"context_token": ctxToken,
"item_list": items,
},
})
if err != nil {
return fmt.Errorf("weixin_ilink: encode sendmessage: %w", err)
}
var out sendResult
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/sendmessage"),
bytes.NewReader(body), &out); err != nil {
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
}
if out.Errcode == errcodeSessionRejected {
// 发送路径与轮询路径看到的是同一份被拒凭据:记录该拒绝状态,让适配器不再
// 宣称会话存活,面板也就可以要求重新扫码。
a.markRejected(rescanReasonSendMessage)
return ErrSessionRejected
}
if out.Ret != 0 {
return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret)
}
return nil
}
// 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)
}
var raw map[string]any
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/getuploadurl"),
bytes.NewReader(body), &raw); err != nil {
return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err)
}
if intOf(raw["errcode"]) == errcodeSessionRejected {
// 媒体上传属于发送路径,所以这里同样记录这份被拒凭据,而不是退化成
//「未返回 url」。
a.markRejected(rescanReasonUploadURL)
return "", ErrSessionRejected
}
if u := strings.TrimSpace(firstString(raw, "upload_full_url")); u != "" {
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。
func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("weixin_ilink: build upload: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: upload media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode)
}
return nil
}
// nativeTextItem 构造一个文本 item_list 条目。
func nativeTextItem(text string) map[string]any {
return map[string]any{
"type": itemText,
"text_item": map[string]any{"text": text},
}
}
// nativeMediaItem 构造一个引用已上传对象的媒体 item_list 条目。同时设置 cdn_ref
// 与 url,因为确切的字段名并未公开。
func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[string]any {
item := map[string]any{
"type": kind,
"cdn_ref": ref,
"url": ref,
"aes_key": base64.StdEncoding.EncodeToString(key),
"file_name": md.Name,
"size": md.Size,
}
if md.MIMEType != "" {
item["mime"] = md.MIMEType
}
return item
}
// nativeMediaTypeFor 把归一化的 MediaKind 映射到 iLink item 类型码。
func nativeMediaTypeFor(kind adapter.MediaKind) int {
switch kind {
case adapter.MediaImage:
return itemImage
case adapter.MediaVoice:
return itemVoice
case adapter.MediaVideo:
return itemVideo
default:
return itemFile
}
}
// 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
}
}
// 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
}