package weixin_ilink import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "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 { // 先拍下本次调用将要使用的凭据快照:长轮询成功时用它置位「已证实」 // (session_status.go),于是「请求发出后配置就被换掉」不会把旧凭据的证据 // 记到新凭据头上。 token := a.botToken() 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 } // 一次成功的 getupdates 就是「iLink 接受了这份凭据」的证据:三态由此从 // 「未知」变成「有效」(session_status.go)。 a.markVerified(token) 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 的 // `ReadMessages` 的 `emit` 与 `SetMessageHandler` 两条投递路径)。文件类附件的字节 // 在**投递之前**内联(切片 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 §4 的 // weixin-ilink 条目:长轮询 + 游标)。 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 } // 结构取证(DEBUG,见 inbound_debug.go):它刻意排在 message_type 判定**之前**, // 于是 `message_type != 1` 的非用户消息也留得下一行,「平台没推」与「推了但不是 // 主人消息」才区分得开。 a.logInboundMediaShape(&rm) 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),图片/视频仍走各自既有链路。 // // 引用在这里一次性归一成**绝对地址**(`cdnURL`):它同时是媒体取回、来源白名单与 // 日志里的 host 字段所看的形态 —— 留在平台给的相对引用上,故障日志只会写「未知来源」, // 而绝对地址在取回时仍要过同一道白名单,不会因此放宽。 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, adapter.ContentText, nil } for i := range media { if media[i].URL != "" { media[i].URL = a.cdnURL(media[i].URL) } } return content, firstMedia, media } // 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。iLink 的媒体引用**正确**位置是 // `_item.media`(见 cdnMediaOf / mediaRefOf);平铺的 url/cdn_ref 只是早期逆向 // 稿的猜测形态,真机从不出现,但仍保留为兜底。 func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media { res := adapter.Media{ Kind: kind, URL: mediaRefOf(m), Name: firstString(m, "file_name", "name"), Size: firstInt64(m, "size", "len", "file_size", "video_size"), MIMEType: firstString(m, "mime", "mime_type", "content_type"), } // 对常见的嵌套子对象做有界扫描,补齐平铺层缺失的元数据(引用已由 mediaRefOf // 覆盖,这里只补名称/体积/类型)。 for _, sub := range mediaSubObjects { inner, ok := m[sub].(map[string]any) if !ok { continue } if res.Name == "" { res.Name = firstString(inner, "file_name", "name") } if res.Size == 0 { res.Size = firstInt64(inner, "size", "len", "file_size", "video_size") } if res.MIMEType == "" { res.MIMEType = firstString(inner, "mime", "mime_type", "content_type") } } return res } // mediaSubObjects 是一个 item 里可能承载媒体载荷的嵌套子对象名。`media` 虽也在此列, // 但它是 **CDNMedia**(引用与密钥),由 cdnMediaOf/mediaRefOf 专门处理。 var mediaSubObjects = []string{ "image_item", "file_item", "voice_item", "video_item", "media_item", "cdn_item", } // cdnMediaOf 返回 item 内嵌的 CDNMedia 子对象(`_item.media`)。官方协议把真正的 // 下载引用与密钥一并放在这里:`full_url` / `encrypt_query_param` / `aes_key`。 func cdnMediaOf(item map[string]any) map[string]any { for _, sub := range mediaSubObjects { inner, ok := item[sub].(map[string]any) if !ok { continue } if media, ok := inner["media"].(map[string]any); ok { return media } } if media, ok := item["media"].(map[string]any); ok { return media } return nil } // mediaRefOf 解析一个媒体 item 的 CDN 引用,返回**待 cdnURL 归一**的引用串:优先 // `media.full_url`(服务端直接给完整地址),其次把 `media.encrypt_query_param` 折成 // CDN 根下的相对下载引用,最后才是平铺/嵌套的猜测键。 func mediaRefOf(item map[string]any) string { if media := cdnMediaOf(item); media != nil { if full := firstString(media, "full_url"); full != "" { return full } if enc := firstString(media, "encrypt_query_param"); enc != "" { return downloadRef(enc) } } if u := firstString(item, "url", "cdn_url", "download_url", "file_url", "cdn_ref"); u != "" { return u } for _, sub := range mediaSubObjects { inner, ok := item[sub].(map[string]any) if !ok { continue } if u := firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref"); u != "" { return u } } return "" } // downloadRef 把 CDN 的加密查询参数折成一个相对下载引用(官方 src/cdn/cdn-url.ts 的 // buildCdnDownloadUrl,只差 CDN 根 —— 根由 cdnURL 拼接)。参数**必须**转义: // encrypt_query_param 是 base64,其中未转义的 `+` 会被服务端当成空格。 func downloadRef(encryptQueryParam string) string { return "download?encrypted_query_param=" + url.QueryEscape(encryptQueryParam) } // 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 中第一个非零整数:既接受 JSON 数字,也接受平台偶以**字符串** // 下发的数值字段(`file_item.len` 就是字符串 `"1234"`)—— 少了后一支,真机上的文件 // 体积会一直是 0,超限预筛就形同虚设。 func firstInt64(m map[string]any, keys ...string) int64 { for _, k := range keys { switch v := m[k].(type) { case float64: if v != 0 { return int64(v) } case string: if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n != 0 { return n } } } return 0 }