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