- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(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 行)
268 lines
8.3 KiB
Go
268 lines
8.3 KiB
Go
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)
|
||
}
|