package weixin_ilink // 本文件承载**入站媒体取证日志**(DEBUG),用来回答一个此前无从取证的问题: // 「模型手里那个媒体地址,是平台给的,还是它自己编的?」 // // 事故背景(2026-10-05 真机):主人用微信发了一张图,回复内容准确(链路本身是通的), // 但同一回合里模型额外调了一次 `vision`,传的地址是 `wechat-image.ilink.example/...` // —— 一个解析不了的域名,调用以「解析主机失败」告终。全仓检索证明这个字面量**不在** // 我们仓库里(只在 `data/cyrene.db-wal` 的那条错误里出现过),因此它只可能来自两处: // 平台载荷,或模型自己编的。而当时**没有任何一条日志**能把这两者区分开。 // // 本文件补上这条取证,两个观测点: // // 1. **入站媒体形状**(`logInboundMediaShape`,从 convertMessage 调用):getupdates // 投递的每一条消息留一行 —— 媒体**项数**、每项的 `type`、引用**来自哪个字段** // (`media.full_url` / `media.encrypt_query_param` / 平铺猜测键)、引用所指的 // **host**,以及载荷里**声明**的 mime/size。 // 2. **取回结局**(`logInboundMediaFetch`,从 ilinkFetcher.Fetch 调用):成功记 // host/类型/字节数(图片另记嗅探所得 mime);失败只记 host/类型/**错误类别**。 // // 有了这两行,同一次故障当场可分:模型编的地址在本文件里**一条形状行都不会有** // (我们压根没收到过带那个 host 的媒体项);平台给的地址则带出 // `host=<平台 CDN 主机>` 与 `ref=<平台字段名>` 的原话。 // // 脱敏口径(红线,与 `plugins/channels/qq_obv11/inbound_debug.go` 同一口径、 // **不跨包引用**):只记结构,不记内容 —— 绝不记完整 URL、绝不记 query 里的 // `encrypted_query_param` / `aes_key` / `bot_token` 的值、绝不记正文、绝不记响应体与 // 错误原文。级别固定 DEBUG(默认 INFO 下行为与改造前完全一致,需要取证时把日志级别 // 调到 debug),并经 `a.log` 打出(`source=plugins/channels/weixin_ilink`)。 // // 「非消息事件」取证:**该渠道不提供**。iLink 的 getupdates 只回一个消息流 `msgs` // (外加 `get_updates_buf` 与 `longpolling_timeout_ms`),没有 OneBot 那样的 // `post_type=meta_event` 心跳/生命周期流(见 DevDocs/reference/weixin_ClawBot_Bridge/ // 对照清单.md 的协议要点:「传输是纯 HTTP/JSON + 长轮询」)。因此这里**不伪造**心跳行; // 能做、也已经做的是:**msgs 里的每一条都留一行**(含 `message_type != 1` 的非用户 // 消息 —— 此前会被 convertMessage 静默丢弃),于是「平台压根没推」与「推了但不是 // 主人消息」这两件事仍然区分得开。 import ( "context" "encoding/json" "errors" "log/slog" "net" "net/url" "strconv" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" ) const ( // inboundDebugMaxItems 是取证行里逐项展开的 item 上限。item_list 由对端构造, // 不设上限时一条消息就能把日志撑成几万项 —— 超出部分只在 `items` 计数与 // `truncated` 标记里体现。 inboundDebugMaxItems = 16 // refNone 表示该媒体 item 没有任何可用的 CDN 引用。 refNone = "none" // unknownRefHost 是引用解析不出主机名时的占位值(绝不退回记原串)。 unknownRefHost = "未知来源" ) // 取回失败的**类别**标签。只记类别,不记错误原文:错误原文可能带上完整 URL // (`*url.Error` 会把地址连同查询串拼进去),而响应体更是绝不能进日志。 const ( errCategoryDNS = "dns" errCategoryHTTPStatus = "http_status" errCategoryOriginDenied = "origin_not_allowed" errCategoryTooLarge = "too_large" errCategoryRedirect = "too_many_redirects" errCategoryTimeout = "timeout" errCategoryBadRef = "bad_reference" errCategoryNoKey = "no_key" errCategoryOther = "other" ) // logInboundMediaShape 打出一条入站消息的**媒体结构**(DEBUG)。它对每条成功解码的 // getupdates 消息都打一行 —— 包括 `message_type != 1` 的非用户消息,否则「平台没推」 // 与「推了但不是主人消息」又会变回一片空白。 // // 投影前先问一句 Enabled:DEBUG 关着时(生产默认)这条路径**一点解析工作都不做**, // 「默认级别下行为与改造前完全一致」因此不只是「不输出」,而是「不算」。 func (a *Adapter) logInboundMediaShape(rm *rawMsg) { if rm == nil || !a.log.Enabled(context.Background(), slog.LevelDebug) { return } types, refs := inboundMediaShape(a, rm.ItemList) a.log.Debug("inbound media shape", "message_type", rm.MessageType, "items", len(rm.ItemList), "item_types", types, "media", refs, "truncated", len(rm.ItemList) > inboundDebugMaxItems, ) } // inboundMediaShape 把 item_list **投影**成两份只含结构的切片:每项的类型标签,以及 // 每个媒体项的「类型 + 引用字段 + 引用 host + 声明的 mime/size」摘要行。正文、文件名、 // URL 原串与一切密钥都不取(解析出的 map 用完即弃)。 func inboundMediaShape(a *Adapter, items []json.RawMessage) (types, refs []string) { limit := len(items) if limit > inboundDebugMaxItems { limit = inboundDebugMaxItems } types = make([]string, 0, limit) for i := 0; i < limit; i++ { var item map[string]any if err := json.Unmarshal(items[i], &item); err != nil { types = append(types, "undecodable") continue } types = append(types, itemKindLabel(intOf(item["type"]))) if line := mediaShapeLine(a, item); line != "" { refs = append(refs, line) } } return types, refs } // itemKindLabel 把平台 item 类型码翻成稳定短标签;未知码保留原值,便于遇到新类型时 // 一眼看出平台到底标了什么。 func itemKindLabel(kind int) string { switch kind { case itemText: return "text" case itemImage: return "image" case itemVoice: return "voice" case itemFile: return "file" case itemVideo: return "video" default: return "other:" + strconv.Itoa(kind) } } // mediaShapeLine 渲染一个媒体 item 的结构摘要,例如 // `image ref=media.encrypt_query_param host=novac2c.cdn.weixin.qq.com`。 // 它只在载荷**确有**媒体引用时返回非空(纯文本 item 不产生行)。 // // host 取的是引用经 `cdnURL` 归一成绝对地址后的**主机名** —— 与取回路径同一条归一, // 因此相对引用显示的是我们自己的 CDN 根主机,而绝对 `full_url` 显示的是平台/对端 // 声称的那台主机。这两者的差别正是取证要看的。 func mediaShapeLine(a *Adapter, item map[string]any) string { ref := mediaRefOf(item) if ref == "" { return "" } // 声明的元数据回用 mediaOf 的同一解析口径(平铺 + 嵌套子对象扫描),不另写一份。 meta := mediaOf(item, adapter.MediaKind("")) line := itemKindLabel(intOf(item["type"])) + " ref=" + mediaRefOrigin(item) + " host=" + a.refHost(ref) if meta.MIMEType != "" { line += " declared_mime=" + meta.MIMEType } if meta.Size != 0 { line += " declared_size=" + strconv.FormatInt(meta.Size, 10) } return line } // mediaRefOrigin 报告引用的**来源字段名**,供取证分辨「平台给了完整地址」与「平台只 // 给了加密查询参数、地址由我们拼」。分支顺序与 mediaRefOf 逐条对应,两者对同一 item // 不可能给出矛盾答案(`TestMediaRefOriginMatchesMediaRefOf` 交叉验证这一点)。 func mediaRefOrigin(item map[string]any) string { if media := cdnMediaOf(item); media != nil { if firstString(media, "full_url") != "" { return "media.full_url" } if firstString(media, "encrypt_query_param") != "" { return "media.encrypt_query_param" } } if k := firstRefGuessKey(item); k != "" { return "item." + k } for _, sub := range mediaSubObjects { inner, ok := item[sub].(map[string]any) if !ok { continue } if k := firstRefGuessKey(inner); k != "" { return sub + "." + k } } return refNone } // firstRefGuessKey 返回平铺/嵌套猜测键里第一个非空串的**键名**(键列表与 mediaRefOf // 同源)。它只回键名,绝不回值 —— 值可能是带签名凭据的地址。 func firstRefGuessKey(m map[string]any) string { return firstStringKey(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref") } // firstStringKey 返回 keys 中第一个非空字符串所在的键名,全空时返回 ""。 func firstStringKey(m map[string]any, keys ...string) string { for _, k := range keys { if s, ok := m[k].(string); ok && s != "" { return k } } return "" } // refHost 返回一个媒体引用归一成绝对地址后的**主机名**(小写,不含端口)。它只回 // 主机名,绝不回路径与查询串(`encrypt_query_param` 就是签名凭据)。解析不出来时给 // 占位值。 func (a *Adapter) refHost(ref string) string { u, err := url.Parse(strings.TrimSpace(a.cdnURL(ref))) if err != nil || u.Hostname() == "" { return unknownRefHost } return strings.ToLower(u.Hostname()) } // logInboundMediaFetch 打出一次入站媒体取回的**结局**(DEBUG)。成功记 host/类型/字节 // 数与(图片才有的)嗅探 mime;失败只记 host/类型/**错误类别**。两类都不记 URL、查询串、 // 响应体与错误原文。 func (a *Adapter) logInboundMediaFetch(m adapter.Media, data []byte, err error) { kind := mediaKindLabel(m.Kind) host := a.refHost(m.URL) if err != nil { a.log.Debug("inbound media fetch failed", "kind", kind, "host", host, "err_kind", fetchErrCategory(err)) return } a.log.Debug("inbound media fetch ok", "kind", kind, "host", host, "bytes", len(data), "mime", sniffedImageMIME(m, data)) } // mediaKindLabel 给出媒体的类型标签(空 Kind 是「只给了消息级内容类型」的形状, // 这里如实标 unknown,绝不猜)。 func mediaKindLabel(kind adapter.MediaKind) string { if kind == "" { return "unknown" } return string(kind) } // sniffedImageMIME 对图片类媒体返回字节嗅探所得的 MIME(非白名单光栅图为空串); // 非图片类一律返回空串 —— 这个字段回答的是「取回来的到底是不是一张图」,不回答别的。 func sniffedImageMIME(m adapter.Media, data []byte) string { if m.Kind != adapter.MediaImage { return "" } // 复用渠道侧取回路径的同一份裁决入口(mediafetch),不另写一份嗅探。 if mime, ok := mediafetch.ImageMIMEOf(data); ok { return mime } return "" } // fetchErrCategory 把一次取回失败归类成一个**稳定标签**。用 errors.As/Is 走错误链, // 绝不按错误文本匹配。分类必须排在别的哨兵之前的是 DNS:一次解析失败的内层错误是 // `*net.DNSError`,它正是本次事故的那一类。 func fetchErrCategory(err error) string { if err == nil { return "" } var dnsErr *net.DNSError switch { case errors.As(err, &dnsErr): return errCategoryDNS case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): return errCategoryTimeout case errors.Is(err, mediafetch.ErrOriginNotAllowed): return errCategoryOriginDenied case errors.Is(err, mediafetch.ErrTooManyRedirects): return errCategoryRedirect case errors.Is(err, mediafetch.ErrTooLarge): return errCategoryTooLarge case errors.Is(err, mediafetch.ErrBadStatus): return errCategoryHTTPStatus case errors.Is(err, mediafetch.ErrEmptyURL), errors.Is(err, mediafetch.ErrNoHost), errors.Is(err, mediafetch.ErrBadScheme): return errCategoryBadRef case errors.Is(err, ErrNoMediaKey), errors.Is(err, ErrKeyNotAES128), errors.Is(err, ErrKeyTooShort): return errCategoryNoKey default: return errCategoryOther } }