feat(weixin_ilink): 出站媒体走通(主人已批的三项)
按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐: ① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param; ③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key, 图片优先 image_item.aeskey)。 配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。 (逐项测试、变异红行与诚实边界见该切片报告)
This commit is contained in:
1 parent
59a1ac4056
commit
f8f2e9c54c
7 files changed
+847
-246
No files matched your search
@@ -3,39 +3,24 @@ 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 类型码,使出站媒体与入站解析映射到
|
||||
// 同一组数字。
|
||||
// 本文件承载**发送编排**:把一条归一化回复切成 item_list 并投递。媒体上传与 item 组装
|
||||
// 在 send_media.go(本文件保持在项目 ≤500 行的单文件上限之内)。
|
||||
|
||||
const (
|
||||
// clientIDBytes / fileKeyBytes 是两处随机标识的字节数。取具名常量,是因为它们
|
||||
// 分别对应协议里的 client_id(去重 ID)与 getuploadurl 的 filekey,不是随手取的数。
|
||||
// clientIDBytes 是发送去重 ID(client_id)的随机字节数:它对应协议里的 client_id,
|
||||
// 不是随手取的数字。
|
||||
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 的响应外壳。
|
||||
@@ -146,56 +131,6 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
|
||||
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{
|
||||
@@ -228,84 +163,6 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
|
||||
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{
|
||||
@@ -314,58 +171,6 @@ func nativeTextItem(text string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
||||
@@ -378,14 +183,3 @@ func randomHex(n int) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user