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

370 lines
11 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"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const (
// msgTypeUser 标记一条入站用户消息(机器人消息的 type 为 2)。
msgTypeUser = 1
// 来自 iLink 参考的 item 类型码(design 03 §1)。
itemText = 1
itemImage = 2
itemVoice = 3
itemFile = 4
itemVideo = 5
// retryFastCount 是使用快速重试的连续失败次数上限。
retryFastCount = 3
// retryFastDelay 是头几次失败前的延迟。
retryFastDelay = 2 * time.Second
// retrySlowDelay 是连续失败超过 retryFastCount 之后的退避延迟。
retrySlowDelay = 30 * time.Second
)
// getupdatesResp 是 getupdates(长轮询)的响应外壳。
type getupdatesResp struct {
// Ret 是顶层业务结果;非零表示瞬时错误。
Ret int `json:"ret"`
// Errcode 是平台错误码;-14 表示 iLink 拒绝了该会话。
Errcode int `json:"errcode"`
// Msgs 保存本次轮询投递的原始入站消息。
Msgs []json.RawMessage `json:"msgs"`
// GetUpdatesBuf 是下一次要回传的游标(Cursor)。
GetUpdatesBuf string `json:"get_updates_buf"`
// LongPollTimeoutMs 是服务端建议的保持时长。
LongPollTimeoutMs int `json:"longpolling_timeout_ms"`
}
// rawMsg 是一条入站 WeixinMessage 的公共字段解析结果。iLink 的媒体字段名只有一部分
// 是公开的,因此 item_list 条目在 parseItem 中做防御式解码。
type rawMsg struct {
ClientID string `json:"client_id"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ContextToken string `json:"context_token"`
GroupID string `json:"group_id"`
RoomID string `json:"room_id"`
ItemList []json.RawMessage `json:"item_list"`
}
// ReadMessages 运行长轮询读循环,直到 ctx 被取消。每条转换后的入站消息,在已通过
// SetMessageHandler 安装了处理器时交给它,否则交给 emit。该循环与 ctx 协作:上下文
// 被取消即立即返回,且没有任何定时器会活过它。
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
a.mu.RLock()
connected := a.connected
a.mu.RUnlock()
if !connected {
a.log.Warn("read loop started while disconnected")
return
}
failures := 0
for {
resp, err := a.fetchUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
// 长轮询超时是正常的「暂无消息」路径。
if errors.Is(err, context.DeadlineExceeded) {
continue
}
failures++
a.log.Warn("getupdates failed", "err", err, "failures", failures)
if !a.wait(ctx, a.retryDelay(failures)) {
return
}
continue
}
failures = 0
if resp.Errcode == errcodeSessionRejected {
// iLink 拒绝了这份会话凭据:重试无济于事,轮询循环会永久变哑。记录显式的
// 被拒绝状态(它同时清掉 connected 标志并以 ERROR 记录日志)并停止循环;
// 主人必须重新扫码,在新 token 到来之前,下游看到的一律是
// ErrSessionRejected。
a.markRejected(rescanReasonGetUpdates)
return
}
if resp.Ret != 0 {
failures++
a.log.Warn("getupdates business error", "ret", resp.Ret)
if !a.wait(ctx, a.retryDelay(failures)) {
return
}
continue
}
a.setCursor(resp.GetUpdatesBuf)
for _, raw := range resp.Msgs {
token, m := a.convertMessage(raw)
if m == nil {
continue
}
a.recordInbound(token, *m)
a.deliver(ctx, *m, emit)
}
if !a.wait(ctx, a.cfg.PollInterval) {
return
}
}
}
// deliver 在已安装处理器时把归一化消息交给它,否则交给 emit 回调
// (design/08 §1:推送式与拉取式投递)。
func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) {
if h := a.handlerLocked(); h != nil {
h(ctx, m)
return
}
if emit != nil {
emit(m)
}
}
// fetchUpdates 执行一次长轮询。若调用方尚未设置更紧的期限,请求上下文以
// LongPollTimeout 为界。
func (a *Adapter) fetchUpdates(ctx context.Context) (*getupdatesResp, error) {
reqCtx, cancel := context.WithTimeout(ctx, a.cfg.LongPollTimeout)
defer cancel()
body, err := json.Marshal(map[string]any{
"get_updates_buf": a.cursor(),
"base_info": map[string]string{"channel_version": channelVersion},
})
if err != nil {
return nil, fmt.Errorf("weixin_ilink: encode getupdates: %w", err)
}
var out getupdatesResp
if err := a.doJSON(reqCtx, http.MethodPost, a.apiURL("/ilink/bot/getupdates"),
bytes.NewReader(body), &out); err != nil {
return nil, fmt.Errorf("weixin_ilink: getupdates: %w", err)
}
return &out, nil
}
// wait 休眠 d,或在 ctx 被取消时返回 false。delay 非正数时立即返回。
func (a *Adapter) wait(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return true
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// retryDelay 在连续失败足够多之后,从快速重试切换到慢速退避(design/08 §5.4)。
func (a *Adapter) retryDelay(failures int) time.Duration {
if failures <= retryFastCount {
return retryFastDelay
}
return retrySlowDelay
}
// convertMessage 把一条原始 iLink 消息映射为 UnifiedMessage,并返回原始
// context_token 以便调用方按对端跟踪它。消息不是入站用户消息、或无法解析时返回 nil。
func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedMessage) {
var rm rawMsg
if err := json.Unmarshal(raw, &rm); err != nil {
a.log.Warn("decode inbound message", "err", err)
return "", nil
}
if rm.MessageType != msgTypeUser {
return "", nil
}
group := resolveGroup(&rm)
m := &adapter.UnifiedMessage{
Platform: platformName,
SenderID: rm.FromUserID,
SenderName: rm.FromUserID,
MessageID: rm.ClientID,
Timestamp: a.clock(),
RawData: raw,
}
if group != "" {
m.ChannelType = adapter.ChannelGroup
m.GroupID = group
m.ChannelID = group
} else {
m.ChannelType = adapter.ChannelDirect
m.ChannelID = rm.FromUserID
}
m.Sender = adapter.Actor{
Role: adapter.RoleUnknown,
UserID: rm.FromUserID,
DisplayName: rm.FromUserID,
IsGroup: group != "",
GroupID: group,
}
m.Content, m.ContentType, m.Media = a.convertItems(rm.ItemList)
return rm.ContextToken, m
}
// convertItems 把 item 列表抽取成文本、一个主内容类型和媒体切片。文本 item 的文本
// 会拼接起来;语音 item 贡献其 ASR 转写;图片/文件/视频 item 以 CDN 引用的形式惰性
// 挂上(轮询期间绝不下载)。
func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) {
var text strings.Builder
var media []adapter.Media
firstMedia := adapter.ContentType("")
for _, raw := range rawItems {
item := parseItem(raw)
switch item.kind {
case itemText:
text.WriteString(item.text)
case itemVoice:
text.WriteString(item.text)
media = append(media, item.media)
firstMedia = adapter.ContentVoice
case itemImage:
media = append(media, item.media)
firstMedia = adapter.ContentImage
case itemFile, itemVideo:
media = append(media, item.media)
firstMedia = adapter.ContentFile
}
}
content := strings.TrimRight(text.String(), "\n")
if firstMedia != "" {
return content, firstMedia, media
}
return content, adapter.ContentText, nil
}
// resolveGroup 在消息携带群标识时返回它,否则返回 ""(私聊)。按 design/08 §4,
// 微信群信号是 ⚠️,因此对多个候选字段做防御式检查。
func resolveGroup(rm *rawMsg) string {
if rm.GroupID != "" {
return rm.GroupID
}
return rm.RoomID
}
// parsedItem 是单个原生 item_list 条目的归一化视图。
type parsedItem struct {
kind int
text string
media adapter.Media
}
// parseItem 防御式地解码一个 item_list 条目。iLink 的媒体字段名并未完全公开,因此
// 用通用 map 配合多个兜底键来扫描 CDN 引用与元数据。
func parseItem(raw json.RawMessage) parsedItem {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return parsedItem{}
}
kind := intOf(m["type"])
switch kind {
case itemText:
return parsedItem{kind: itemText, text: textOf(m, "text_item")}
case itemVoice:
return parsedItem{kind: itemVoice, text: textOf(m, "voice_item"), media: mediaOf(m, adapter.MediaVoice)}
case itemImage:
return parsedItem{kind: itemImage, media: mediaOf(m, adapter.MediaImage)}
case itemFile:
return parsedItem{kind: itemFile, media: mediaOf(m, adapter.MediaFile)}
case itemVideo:
return parsedItem{kind: itemVideo, media: mediaOf(m, adapter.MediaVideo)}
default:
return parsedItem{kind: kind}
}
}
// textOf 从指定的嵌套子对象读取文本载荷,兜底回退到平铺的 "text" 键。
func textOf(m map[string]any, sub string) string {
if inner, ok := m[sub].(map[string]any); ok {
if s, ok := inner["text"].(string); ok {
return s
}
}
if s, ok := m["text"].(string); ok {
return s
}
return ""
}
// mediaOf 从媒体 item 构造 adapter.Media,从平铺 item 或嵌套子对象解析 CDN URL
// 与元数据。
func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
res := adapter.Media{Kind: kind}
res.URL = firstString(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
res.Name = firstString(m, "file_name", "name")
res.Size = firstInt64(m, "size", "file_size")
res.MIMEType = firstString(m, "mime", "mime_type", "content_type")
if res.URL != "" {
return res
}
// 对常见的嵌套子对象做有界扫描,寻找 CDN 引用。
for _, sub := range []string{"image_item", "file_item", "video_item", "media_item", "cdn_item"} {
inner, ok := m[sub].(map[string]any)
if !ok {
continue
}
res.URL = firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
if res.Name == "" {
res.Name = firstString(inner, "file_name", "name")
}
if res.Size == 0 {
res.Size = firstInt64(inner, "size", "file_size")
}
if res.MIMEType == "" {
res.MIMEType = firstString(inner, "mime", "mime_type", "content_type")
}
if res.URL != "" {
return res
}
}
return res
}
// intOf 把 JSON 数字强制转换为 int,缺失或非数字时返回 0。
func intOf(v any) int {
f, ok := v.(float64)
if !ok {
return 0
}
return int(f)
}
// firstString 返回 keys 中第一个非空字符串。
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
// firstInt64 返回 keys 中第一个非零整数。
func firstInt64(m map[string]any, keys ...string) int64 {
for _, k := range keys {
if f, ok := m[k].(float64); ok && f != 0 {
return int64(f)
}
}
return 0
}