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 }