- 抽共享存储核心: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,与本次无关)。
291 lines
13 KiB
Go
291 lines
13 KiB
Go
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]) + "…"
|
||
}
|