feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)

This commit is contained in:
AskaEth committed 2026-10-05 11:22:26 +08:00
1 parent 0cbc9ff886
commit b73a1276c6
18 files changed
+1224 -121

No files matched your search

@@ -14,9 +14,11 @@ package channelbridge
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
//
// 四条边界:
// - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路);**语音也不走这里** ——
// 入站语音由 `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回
// `Content`(切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
// - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路)—— 入站图片由
// `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
// (切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
// 被拒的消息因此绝不会在盘上留下文件;
@@ -76,24 +78,18 @@ const (
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")
}
// 制品形状(`inboundContent`:部件 + 诚实提示行)与挂载点由 inbound_content.go 给出 ——
// 图片路与本路共用那一份,两条路只各自贡献自己的部件与自己的提示行。
// documentColumn 把本次摄取产出的文档部件编码成落库用的 `document` 列(design/24
// §2.4 的引用数组)。它与 Web 上传走**同一个字段、同一个结构体**:history.DocumentRef
// 同时就是历史 DTO 里 `documents` 数组的元素形状,因此两边客户端读到的附件芯片
// 逐字一致(名字/MIME/大小取自服务端,客户端给的原始文件名与声明类型都不在其中)。
//
// 它只对**文档部件**成立(部件带 DocumentName/MIME/Size):图片部件走的是消息 DTO 的
// `image` 字段(inbound_images.go 的 inboundImageColumn),**绝不进这一列** —— 把一张
// data: URI 当成附件引用落进 `document` 列,客户端会拿到一个指向不存在文件的芯片。
//
// 与网关侧 documentRefOf(session_persist.go)是同一份映射:那里未导出、且本包
// 不许反向依赖网关,因此这里按同一口径重写一份 —— 两侧任一改动都必须同时改。
//
@@ -101,10 +97,13 @@ func (a inboundAttachments) noticeText() string {
// 要等回合跑起来(装配期解析)才存在,由 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})
func (c inboundContent) documentColumn() (string, error) {
refs := make([]history.DocumentRef, 0, len(c.parts))
for _, part := range c.parts {
if part.Type != model.ContentTypeDocument {
continue
}
refs = append(refs, history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize})
}
return history.EncodeDocuments(refs)
}
@@ -113,7 +112,7 @@ func (a inboundAttachments) documentColumn() (string, error) {
// (只可能是坏数据或超限)绝不静默:打一条带来源的 ERROR,并让这条消息按「没有
// 附件」落库 —— 丢一条消息远比丢一个附件引用严重,而那条写不进去的引用也绝不
// 假装存在(与 Web 侧 persistUserMessages 的同一取舍)。
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundAttachments) string {
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundContent) string {
if len(attach.parts) == 0 {
return ""
}
@@ -128,7 +127,8 @@ func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboun
}
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
// 语音那一半见 inbound_voice.go 的 hasVoiceMedia / hasUsableMedia。
// 语音与图片那两半见 inbound_voice.go 的 hasVoiceMedia 与 inbound_images.go 的
// hasImageMedia(三者合起来是 hasUsableMedia)。
func hasFileMedia(msg adapter.UnifiedMessage) bool {
for _, m := range msg.Media {
if isFileMedia(msg, m) {
@@ -163,17 +163,17 @@ func fileMedia(msg adapter.UnifiedMessage) []adapter.Media {
// ingestInboundDocuments 把一条入站消息的文件类媒体落进共享文档存储,返回要挂到
// 该消息上的文档部件与「读不到」的诚实提示。调用点必须在**放行之后**:被黑白名单
// 或最小打扰拦下的消息不会在盘上留下任何文件。
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundAttachments {
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundContent {
files := fileMedia(msg)
if len(files) == 0 {
return inboundAttachments{}
return inboundContent{}
}
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))}
out := inboundContent{notices: make([]string, 0, len(files))}
for _, m := range files {
out.notices = append(out.notices, noticeLine(m, inboundNoticeUnwired))
}
@@ -185,8 +185,8 @@ func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.Unified
// ingestFiles 逐个落盘并分类:成功挂文档部件,被拒/无字节则记一条带来源的日志 +
// 一行诚实提示。条数上限与 Web 侧同源(documents.MaxPerMessage),超出的附件
// **不静默**:整条提示一次,且日志记下实际条数。
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundAttachments {
out := inboundAttachments{
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundContent {
out := inboundContent{
parts: make([]model.Content, 0, len(files)),
notices: make([]string, 0, len(files)),
}
@@ -293,17 +293,28 @@ func attachmentSize(media adapter.Media) int64 {
return int64(len(media.Data))
}
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:去控制字符、
// 折叠空白、限长;净化后为空时给占位名。
func displayName(name string) string {
// cleanDisplayName 把渠道给的原始名字净化成可安全放进模型上下文的展示名:剔控制字符
// (换行会让一条名字在模型上下文里伪造出额外的行)、折叠空白、限长;净化后为空时返回
// 空串,由调用方给占位 —— 文件与图片的占位**刻意不同**(displayName /
// imageDisplayName:把一张图叫成「未命名文件」会让模型对这条媒体的类型产生错误印象)。
func cleanDisplayName(name string) string {
cleaned := strings.Map(dropAttachmentControl, name)
cleaned = strings.Join(strings.Fields(cleaned), " ")
if cleaned == "" {
return inboundAttachmentUnknownName
return ""
}
return truncateRunes(cleaned, inboundAttachmentNameMaxRunes)
}
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:净化见
// cleanDisplayName;净化后为空时给「未命名文件」占位。
func displayName(name string) string {
if cleaned := cleanDisplayName(name); cleaned != "" {
return cleaned
}
return inboundAttachmentUnknownName
}
// displayMIME 只保留 MIME 里合法的字符(字母/数字与 `/+;=-.` 与空白),其余一律
// 丢弃 —— 它同样是渠道给的字符串,绝不原样进上下文;净化后为空时给「未知类型」。
func displayMIME(mime string) string {