采集面: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),对齐留待下次改动。
345 lines
17 KiB
Go
345 lines
17 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_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]) + "…"
|
||
}
|