Files
Cyrene_v2/plugins/channels/weixin_ilink/send.go
T
AskaEth 95f97f5df3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
2026-10-04 12:29:05 +08:00

268 lines
8.3 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/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// item 类型常量复用 poll.go 里的 iLink item 类型码,使出站媒体与入站解析映射到
// 同一组数字。
// 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。
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))
}
for _, md := range m.Media {
item, err := a.buildMediaItem(ctx, md)
if err != nil {
return fmt.Errorf("weixin_ilink: build media item for %q: %w", md.Kind, err)
}
items = append(items, item)
}
if len(items) == 0 {
return nil
}
return a.sendNative(ctx, peer, ctxToken, items)
}
// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象及其 AES 密钥的
// 原生 item。
func (a *Adapter) buildMediaItem(ctx context.Context, 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)
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
}
// 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 上传 URL。响应字段名在不同服务端版本间有
// 差异,所以会尝试多个候选键。
func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
body, err := json.Marshal(map[string]any{})
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
}
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
if u == "" {
return "", fmt.Errorf("weixin_ilink: getuploadurl returned no url")
}
return u, nil
}
// 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
}
}
// newClientID 返回随机的十六进制 client id,用于发送去重。若(极小概率的)
// crypto/rand 失败,则退回到基于时间的值。
func newClientID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
return "c-" + strconv.FormatInt(time.Now().UnixNano(), 36)
}