package telegram // 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1 / IM-IMG):Bot API 上报的 // **文件类与图片类**媒体都只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id // 换成一个**平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载(两步同一条 // 路、同一份来源白名单;图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限)。 // // 三条边界: // - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机; // 平台返回的路径还要过 `sanitizeFilePath`(拒绝对协议前缀、`..`、查询串); // - 来源白名单 = 已配置的 API 根(默认 `https://api.telegram.org`):白名单之外的 // 主机**一个字节都不发出去**; // - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用), // 这里只负责「把平台引用换成一个平台资产地址」。 import ( "context" "encoding/json" "fmt" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" ) // getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。 const getFileMethod = "getFile" // inlineInboundMedia 在消息投递给上层之前,把受支持格式的附件(**文件类与图片类**)取回并 // 内联。失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型 // 一行诚实提示(design/24 §4.4)。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a}) } // telegramFetcher 实现 mediafetch.Fetcher:Telegram 给的是 `file_id`,因此它必须 // 先问平台「这个 id 对应哪个路径」,再下载。 type telegramFetcher struct{ a *Adapter } // Fetch 取回一份 Telegram 附件的字节。体积上限与超时都不在这里写数:前者由 // mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量 // `mediafetch.DefaultTimeout`。 func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { filePath, err := f.a.filePath(ctx, m.URL) if err != nil { return nil, err } return mediafetch.Fetch(ctx, mediafetch.Request{ URL: f.a.fileURL(filePath), Allow: f.a.assetAllowlist(), Client: f.a.client, Timeout: mediafetch.DefaultTimeout, }) } // assetAllowlist 给出文件与**图片**下载的来源白名单:**只有已配置的 Bot API 根**(图片不 // 另立名单 —— 两者落的是同一台主机,另立一份只会多一处漂移)。文件地址 // 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把 // 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { origin, err := mediafetch.OriginOf(a.baseURL) if err != nil { return mediafetch.NewAllowlist() } return mediafetch.NewAllowlist(origin) } // fileURL 把平台给出的相对路径拼成下载地址。token 在路径里(与 endpoint 同口径), // 绝不进查询串;这个地址**只用于请求**,绝不进日志(mediafetch 的错误只带来源)。 func (a *Adapter) fileURL(filePath string) string { return a.baseURL + "/file/bot" + a.cfg.BotToken + "/" + strings.TrimLeft(filePath, "/") } // filePath 调 getFile 把平台文件 id 换成平台自己返回的相对路径,并做形态校验。 func (a *Adapter) filePath(ctx context.Context, fileID string) (string, error) { if strings.TrimSpace(fileID) == "" { return "", ErrNoFilePath } out, err := a.doJSON(ctx, getFileMethod, map[string]any{"file_id": fileID}) if err != nil { return "", err } var res struct { FilePath string `json:"file_path"` } if err := json.Unmarshal(out.Result, &res); err != nil { return "", fmt.Errorf("telegram: getFile result: %w", err) } return sanitizeFilePath(res.FilePath) } // sanitizeFilePath 校验平台返回的文件路径:非空、不含协议前缀 / `..` / 查询串 / // 控制字符。校验失败时返回 ErrNoFilePath —— 绝不把可疑路径拼进下载地址。 func sanitizeFilePath(filePath string) (string, error) { path := strings.TrimSpace(filePath) if path == "" { return "", ErrNoFilePath } for _, r := range path { if r < ' ' || r == 0x7f { return "", fmt.Errorf("%w: 平台返回的路径含控制字符", ErrNoFilePath) } } if strings.Contains(path, "://") || strings.Contains(path, "..") || strings.ContainsAny(path, "\\?#") { return "", fmt.Errorf("%w: 平台返回的路径形态非法", ErrNoFilePath) } return path, nil }