Files
Cyrene_v2/internal/services/channelbridge/inbound_documents.go
T
AskaEth 2a43035f5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00

291 lines
13 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 channelbridge
// 本文件归属 **IM 入站附件摄取**(design/24 §3 / §4.4;切片 M1):把入站消息携带的
// 文件类媒体交给共享文档存储(`internal/runtime/documents` 的 Store —— 与 Web
// 上传同一份实现),成功者挂成该消息的文档部件,让装配期 `resolveDocuments`
// 照既有链路完成抽取/注入/清单/结论回写,**不需要新链路**。
//
// 读不到的附件**绝不静默**(design/24 §4.4):模型会收到一行诚实提示,日志留一条
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
//
// 四条边界:
// - **图片/语音不走这里**(design/24 §4.2 末:图片走既有视觉链路),本切片只补
// 文件类;
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
// 被拒的消息因此绝不会在盘上留下文件;
// - 输出仍只走 chat 文本:IM 侧不新增任何文件出站能力。
import (
"context"
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 入站附件的具名常量与提示文案(design/24 §4.4)。提示行的形状与循环的清单行
// 一致(`[附件 <名> · …]`),但**只进模型可见视图**,不写进持久化正文 ——
// design/24 §4.3 要求人类可见的来源标记归客户端渲染层,绝不拼进正文。
const (
// inboundAttachmentNameMaxRunes 是提示行里原始文件名的展示上限。文件名来自
// 渠道(发送方可任意构造),因此限长之外还要**剔控制字符**:换行会让一条
// 附件名在模型上下文里伪造出额外的行。
inboundAttachmentNameMaxRunes = 64
// inboundAttachmentMIMEMaxRunes 是提示行里媒体类型的展示上限。
inboundAttachmentMIMEMaxRunes = 48
// inboundAttachmentUnknownName 是文件名缺失(或净化后为空)时的占位。
inboundAttachmentUnknownName = "未命名文件"
// inboundAttachmentUnknownMIME 是渠道未给 MIME 时的展示值(与循环清单行的
// 「未知类型」同一口径:宁可写出来,也不留一个空洞让模型误读)。
inboundAttachmentUnknownMIME = "未知类型"
// inboundUploaderPrefix 是写进 `documents` 元数据的上传者身份前缀,用来与
// Web 账号身份分开(渠道来源 vs 登录账号)。
inboundUploaderPrefix = "im:"
// 诚实提示行(design/24 §4.4)。参考 `%s=展示名`、`%s=展示 MIME`、`%d=字节数`。
// 文案按**拒绝原因**分类:把「超限」说成「格式不支持」本身就是一种不诚实。
inboundNoticeExtDenied = "[附件 %s · %s · %d 字节 · 无法读取:格式不在可读白名单内(只支持纯文本类文档)]"
inboundNoticeNotText = "[附件 %s · %s · %d 字节 · 无法读取:内容不是纯文本(类型由字节嗅探裁决)]"
inboundNoticeTooLarge = "[附件 %s · %s · %d 字节 · 无法读取:文件超过服务端体积上限]"
inboundNoticeEmpty = "[附件 %s · %s · %d 字节 · 无法读取:文件为空]"
inboundNoticeStoreError = "[附件 %s · %s · %d 字节 · 无法读取:服务端保存该附件失败]"
inboundNoticeNoBytes = "[附件 %s · %s · %d 字节 · 无法读取:渠道只给了远程引用,服务端未代取文件字节]"
inboundNoticeUnwired = "[附件 %s · %s · %d 字节 · 无法读取:服务端未接线文档存储]"
inboundNoticeTooMany = "[另有附件未处理:单条消息最多 %d 个文件]"
)
// inboundAttachments 是一次入站附件摄取的产物。
type inboundAttachments struct {
// parts 是已落库的文档部件(挂在同一条 RoleUser 消息上)。
parts []model.Content
// notices 是「有附件读不到」的诚实提示行,顺序与消息里的文件媒体顺序一致。
notices []string
}
// noticeText 把提示行拼成一段文本;没有提示时返回空串(调用方据此不塞空文本部件)。
func (a inboundAttachments) noticeText() string {
return strings.Join(a.notices, "\n")
}
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
func hasFileMedia(msg adapter.UnifiedMessage) bool {
for _, m := range msg.Media {
if isFileMedia(msg, m) {
return true
}
}
return false
}
// isFileMedia 报告一条媒体是不是**文件类**。部分适配器只给内容类型而不给媒体
// Kind(`ContentType == file`,附件就在 Media 里),那种消息也按文件处理,否则
// 附件会被静默忽略 —— 静默忽略正是本切片要消灭的行为。
func isFileMedia(msg adapter.UnifiedMessage, m adapter.Media) bool {
if m.Kind == adapter.MediaFile {
return true
}
return m.Kind == "" && msg.ContentType == adapter.ContentFile
}
// fileMedia 返回消息里全部文件类媒体(保序)。
func fileMedia(msg adapter.UnifiedMessage) []adapter.Media {
out := make([]adapter.Media, 0, len(msg.Media))
for _, m := range msg.Media {
if isFileMedia(msg, m) {
out = append(out, m)
}
}
return out
}
// ingestInboundDocuments 把一条入站消息的文件类媒体落进共享文档存储,返回要挂到
// 该消息上的文档部件与「读不到」的诚实提示。调用点必须在**放行之后**:被黑白名单
// 或最小打扰拦下的消息不会在盘上留下任何文件。
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundAttachments {
files := fileMedia(msg)
if len(files) == 0 {
return inboundAttachments{}
}
store := b.inboundDocumentStore(msg)
if store == nil {
// 未接线:绝不假装读到了。每个附件都得到一行诚实提示 + 一条带来源的日志。
b.log.Warn("inbound attachment ingest skipped: document store not wired",
"platform", msg.Platform, "channel", msg.ChannelID, "files", len(files))
out := inboundAttachments{notices: make([]string, 0, len(files))}
for _, m := range files {
out.notices = append(out.notices, noticeLine(m, inboundNoticeUnwired))
}
return out
}
return b.ingestFiles(ctx, store, msg, files)
}
// ingestFiles 逐个落盘并分类:成功挂文档部件,被拒/无字节则记一条带来源的日志 +
// 一行诚实提示。条数上限与 Web 侧同源(documents.MaxPerMessage),超出的附件
// **不静默**:整条提示一次,且日志记下实际条数。
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundAttachments {
out := inboundAttachments{
parts: make([]model.Content, 0, len(files)),
notices: make([]string, 0, len(files)),
}
for i, media := range files {
if i >= documents.MaxPerMessage {
b.log.Warn("inbound attachments beyond per-message limit ignored",
"platform", msg.Platform, "channel", msg.ChannelID,
"files", len(files), "limit", documents.MaxPerMessage)
out.notices = append(out.notices, fmt.Sprintf(inboundNoticeTooMany, documents.MaxPerMessage))
break
}
if len(media.Data) == 0 {
// 渠道只给了远程引用(CDN URL / 平台文件 id):本切片**不代取** ——
// 服务端代取任意 http(s) 正是 F3 的教训(design/24 §5),需要专门
// 的裁决与地址守卫,不在这里顺手打开。绝不静默。
b.log.Warn("inbound attachment has no inlined bytes",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", media.Name, "mime", media.MIMEType, "size", media.Size)
out.notices = append(out.notices, noticeLine(media, inboundNoticeNoBytes))
continue
}
ref, err := store.Save(ctx, media.Name, media.Data)
if err != nil {
// 白名单/嗅探/限额的裁决全在 store(与 Web 上传同源)。这里只如实
// 记日志(name/mime/size,**绝不记正文**)并让模型看到「读不到」,
// 且提示文案按**拒绝原因**分类(把「超限」说成「格式不支持」也是一种
// 不诚实)。
b.log.Warn("inbound attachment rejected",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", media.Name, "mime", media.MIMEType, "size", len(media.Data), "error", err)
out.notices = append(out.notices, noticeLine(media, rejectedNoticeFor(err)))
continue
}
b.log.Info("inbound attachment stored",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", ref.Name, "mime", ref.MIME, "size", ref.Size,
"sha256", ref.SHA256, "deduped", ref.Deduped)
out.parts = append(out.parts, documentPart(ref))
}
return out
}
// inboundDocumentStore 按消息的渠道身份构造文档存储:目录/上限/元数据写入面来自
// 组合根注入的配置,上传者身份按消息给出。未接线(UploadsDir 与 DataDir 都为空)
// 时返回 nil —— 调用方据此走「读不到」的诚实路径,而不是假装读到了。
func (b *Bridge) inboundDocumentStore(msg adapter.UnifiedMessage) *documents.Store {
cfg := b.documentsCfg
if cfg.UploadsDir == "" && cfg.DataDir == "" {
return nil
}
cfg.Uploader = inboundUploader(msg)
return documents.NewStore(cfg)
}
// inboundUploader 给出写进 `documents` 元数据的上传者身份(平台 + 本地账号 +
// 平台 uid)。身份**只来自渠道元数据**,绝不从文件名或内容里推断(项目红线)。
func inboundUploader(msg adapter.UnifiedMessage) string {
return inboundUploaderPrefix + deviceIDFor(msg)
}
// documentPart 把一条已落库的附件折成模型部件。身份一律取自**服务端存储名**
// (原始文件名永不进上下文);URL 刻意留空:IM 侧没有文件出站能力、装配期也
// 用不到读侧 URL,而落盘路径(Ref.Path)更是绝不能进模型上下文。
func documentPart(ref documents.Ref) model.Content {
msg := model.DocumentMessage(model.RoleUser, "", model.Document{
Name: ref.Name,
MIME: ref.MIME,
Size: ref.Size,
})
return msg.Content[0]
}
// rejectedNoticeFor 按 store 的哨兵错误给出对应的诚实提示模板(design/24 §4.4):
// 分类只依据哨兵链,**错误原文只进日志、绝不进模型上下文**。
func rejectedNoticeFor(err error) string {
switch {
case errors.Is(err, documents.ErrExtNotAllowed):
return inboundNoticeExtDenied
case errors.Is(err, documents.ErrContentNotText):
return inboundNoticeNotText
case errors.Is(err, documents.ErrTooLarge):
return inboundNoticeTooLarge
case errors.Is(err, documents.ErrEmpty):
return inboundNoticeEmpty
default:
return inboundNoticeStoreError
}
}
// noticeLine 渲染一行诚实提示(design/24 §4.4)。名字与媒体类型都来自渠道,
// 因此先做展示净化:剔控制字符、限长、缺失给占位。
func noticeLine(media adapter.Media, template string) string {
return fmt.Sprintf(template, displayName(media.Name), displayMIME(media.MIMEType), attachmentSize(media))
}
// attachmentSize 取附件的展示字节数:优先用渠道上报的大小,缺失或为负时退回
// 实际拿到的内联字节数 —— 提示行里的数字宁可是「已知的最小值」,也不能是负数。
func attachmentSize(media adapter.Media) int64 {
if media.Size > 0 {
return media.Size
}
return int64(len(media.Data))
}
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:去控制字符、
// 折叠空白、限长;净化后为空时给占位名。
func displayName(name string) string {
cleaned := strings.Map(dropAttachmentControl, name)
cleaned = strings.Join(strings.Fields(cleaned), " ")
if cleaned == "" {
return inboundAttachmentUnknownName
}
return truncateRunes(cleaned, inboundAttachmentNameMaxRunes)
}
// displayMIME 只保留 MIME 里合法的字符(字母/数字与 `/+;=-.` 与空白),其余一律
// 丢弃 —— 它同样是渠道给的字符串,绝不原样进上下文;净化后为空时给「未知类型」。
func displayMIME(mime string) string {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '/', r == '+', r == ';', r == '=', r == '-', r == '.', r == ' ':
return r
default:
return -1
}
}, mime)
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return inboundAttachmentUnknownMIME
}
return truncateRunes(cleaned, inboundAttachmentMIMEMaxRunes)
}
// dropAttachmentControl 把换行/制表折叠为空格,其余控制字符直接丢弃。
func dropAttachmentControl(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}
// truncateRunes 按 rune 截断并加省略号(绝不把多字节字符切成两半)。
func truncateRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
return string([]rune(s)[:max]) + "…"
}