Files
Cyrene_v2/plugins/channels/weixin_ilink/inbound_debug.go
T
AskaEth f44114301e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 微信入站媒体脱敏取证日志(形状行记 ref 来源+host、结局行记 err_kind;不记 URL/query/密钥)
2026-10-05 12:07:18 +08:00

292 lines
12 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
// 本文件承载**入站媒体取证日志**(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
}
}