Files
Cyrene_v2/internal/services/channelbridge/inbound_documents.go
T
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

345 lines
17 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`
// 照既有链路完成抽取/注入/清单/结论回写,**不需要新链路**。
//
// 同样的产物还必须是**用户消息落库时的 `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]) + "…"
}