Files
Cyrene_v2/plugins/channels/weixin_ilink/poll.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

373 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:推送式与拉取式投递)。文件类附件的字节在**投递之前**内联
// (切片 IM-1):轮询是这条适配器唯一的入站路径,因此这里是唯一的决策点。
func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) {
a.inlineInboundMedia(ctx, &m)
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 引用的形式
// 挂上(转换期不下载);**文件类**附件的字节随后由 deliver → inlineInboundMedia 在
// 受支持格式与限额内取回并内联(切片 IM-1),图片/视频仍走各自既有链路。
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
}