package channelbridge // 本文件归属 **IM 入站附件摄取**(design/24 §3 / §4.4;切片 M1):把入站消息携带的 // 文件类媒体交给共享文档存储(`internal/runtime/documents` 的 Store —— 与 Web // 上传同一份实现),成功者挂成该消息的文档部件,让装配期 `resolveDocuments` // 照既有链路完成抽取/注入/清单/结论回写,**不需要新链路**。 // // 同样的产物还必须是**用户消息落库时的 `document` 列**(documentColumn):只把部件 // 挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片只认 // 库里的那一列。结论(status/chars/truncated/reason)随后由 // persistTurnDocumentStatus 补进同一列(inbound_documents_persist.go)。 // // 读不到的附件**绝不静默**(design/24 §4.4):模型会收到一行诚实提示,日志留一条 // 带来源的记录(只记 name/mime/size,**绝不记正文**)。 // // 四条边界: // - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路);**语音也不走这里** —— // 入站语音由 `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 // `Content`(切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算; // - 入站附件是**用户内容** ⇒ 文档部件只挂在 `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/channel/mediafetch" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "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 覆盖「服务端没拿到字节」这一类:渠道只给了远程引用、或 // 代取失败(切片 IM-1 起,受支持格式的附件由**适配器**在入站时按平台资产白名单 // 取回并内联;取不到时字节仍为空,走这一行)。文案对两种成因都成立 —— 把「代取 // 失败」说成「我们根本不代取」也是一种不诚实。 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") } // documentColumn 把本次摄取产出的文档部件编码成落库用的 `document` 列(design/24 // §2.4 的引用数组)。它与 Web 上传走**同一个字段、同一个结构体**:history.DocumentRef // 同时就是历史 DTO 里 `documents` 数组的元素形状,因此两边客户端读到的附件芯片 // 逐字一致(名字/MIME/大小取自服务端,客户端给的原始文件名与声明类型都不在其中)。 // // 与网关侧 documentRefOf(session_persist.go)是同一份映射:那里未导出、且本包 // 不许反向依赖网关,因此这里按同一口径重写一份 —— 两侧任一改动都必须同时改。 // // 这里只落**引用**,不落结论:chars/truncated/status 是**本回合注入侧**的事实, // 要等回合跑起来(装配期解析)才存在,由 persistTurnDocumentStatus 在回合结束后 // 补进同一列。没有附件时返回空串(与 EncodeDocuments 对空列表的口径一致:空串 // ⇒ DTO 里该键省略)。 func (a inboundAttachments) documentColumn() (string, error) { refs := make([]history.DocumentRef, 0, len(a.parts)) for _, c := range a.parts { refs = append(refs, history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize}) } return history.EncodeDocuments(refs) } // inboundDocumentColumn 取本回合要写进用户消息 `document` 列的引用串。编码失败 // (只可能是坏数据或超限)绝不静默:打一条带来源的 ERROR,并让这条消息按「没有 // 附件」落库 —— 丢一条消息远比丢一个附件引用严重,而那条写不进去的引用也绝不 // 假装存在(与 Web 侧 persistUserMessages 的同一取舍)。 func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundAttachments) string { if len(attach.parts) == 0 { return "" } column, err := attach.documentColumn() if err != nil { b.log.Error("inbound attachment refs not persisted", "platform", msg.Platform, "channel", msg.ChannelID, "documents", len(attach.parts), "error", err) return "" } return column } // hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。 // 语音那一半见 inbound_voice.go 的 hasVoiceMedia / hasUsableMedia。 func hasFileMedia(msg adapter.UnifiedMessage) bool { for _, m := range msg.Media { if isFileMedia(msg, m) { return true } } return false } // isFileMedia 报告一条媒体是不是**文件类**。部分适配器只给内容类型而不给媒体 // Kind(`ContentType == file`,附件就在 Media 里),那种消息也按文件处理,否则 // 附件会被静默忽略 —— 静默忽略正是本切片要消灭的行为。 // // 判定本身只有一份、在 `mediafetch.IsFileMedia`:桥接器的入站摄取与适配器的字节内联 // 必须对「哪些附件该被处理」给出同一个答案,否则会出现「内联了但没人消费」(切片 // IM-ASR 顺手去重,两处不再是两份实现)。 func isFileMedia(msg adapter.UnifiedMessage, m adapter.Media) bool { return mediafetch.IsFileMedia(&msg, m) } // 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)而字节仍为空:切片 IM-1 起, // **适配器**在入站时会按平台资产白名单把受支持格式的附件取回并内联,因此 // 走到这里意味着「渠道没给内容」或「代取失败」。服务端代取**任意 http(s) // URL** 仍是 F3 的教训(需要专门裁决与地址守卫),而代取**平台自己的资产** // 由适配器按来源白名单完成 —— 两者不是同一件事。绝不静默。 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]) + "…" }