110 lines
4.8 KiB
Go
110 lines
4.8 KiB
Go
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
|
||
}
|