- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
361 lines
18 KiB
Go
361 lines
18 KiB
Go
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_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
|
||
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
|
||
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
|
||
// (切片 IM-ASR;design/06 §1.1),其**音频副本**另走
|
||
// `inbound_voice_copy.go`(闸门之后、内容寻址、`voice` 列;切片 CODE-VOICE):
|
||
// 语音**不进文档的摄取/注入链路**(不抽取、不进 `documents` DTO 数组、不占注入
|
||
// 预算、不出现在附件清单行里),但它**复用同一份存储与 GC**(同一个
|
||
// `<DataDir>/uploads/documents` 目录与 `documents` 元数据表,design/24 §10.5)——
|
||
// 也就是说「不走这里」指的是**摄取语义**,不是「不落盘」;
|
||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `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 个文件]"
|
||
)
|
||
|
||
// 制品形状(`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)是同一份映射:那里未导出、且本包
|
||
// 不许反向依赖网关,因此这里按同一口径重写一份 —— 两侧任一改动都必须同时改。
|
||
//
|
||
// 这里只落**引用**,不落结论:chars/truncated/status 是**本回合注入侧**的事实,
|
||
// 要等回合跑起来(装配期解析)才存在,由 persistTurnDocumentStatus 在回合结束后
|
||
// 补进同一列。没有附件时返回空串(与 EncodeDocuments 对空列表的口径一致:空串
|
||
// ⇒ DTO 里该键省略)。
|
||
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)
|
||
}
|
||
|
||
// inboundDocumentColumn 取本回合要写进用户消息 `document` 列的引用串。编码失败
|
||
// (只可能是坏数据或超限)绝不静默:打一条带来源的 ERROR,并让这条消息按「没有
|
||
// 附件」落库 —— 丢一条消息远比丢一个附件引用严重,而那条写不进去的引用也绝不
|
||
// 假装存在(与 Web 侧 persistUserMessages 的同一取舍)。
|
||
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundContent) 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 与 inbound_images.go 的
|
||
// hasImageMedia(三者合起来是 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) inboundContent {
|
||
files := fileMedia(msg)
|
||
if len(files) == 0 {
|
||
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 := inboundContent{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) inboundContent {
|
||
out := inboundContent{
|
||
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))
|
||
}
|
||
|
||
// cleanDisplayName 把渠道给的原始名字净化成可安全放进模型上下文的展示名:剔控制字符
|
||
// (换行会让一条名字在模型上下文里伪造出额外的行)、折叠空白、限长;净化后为空时返回
|
||
// 空串,由调用方给占位 —— 文件与图片的占位**刻意不同**(displayName /
|
||
// imageDisplayName:把一张图叫成「未命名文件」会让模型对这条媒体的类型产生错误印象)。
|
||
func cleanDisplayName(name string) string {
|
||
cleaned := strings.Map(dropAttachmentControl, name)
|
||
cleaned = strings.Join(strings.Fields(cleaned), " ")
|
||
if cleaned == "" {
|
||
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 {
|
||
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]) + "…"
|
||
}
|