Files
Cyrene_v2/internal/services/channelbridge/inbound_documents_test.go
T
AskaEth 2a43035f5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00

396 lines
14 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):
//
// - 文件类媒体 → 共享文档存储落库 → 装配期抽取 → **模型可见视图里出现清单行与
// 正文**(真 store + 真抽取器 + 真循环 + 假模型,端到端);
// - 不支持的格式**不落库**,但模型收到一行诚实提示、日志留一条带来源的记录,
// 且日志**绝不记正文**;
// - 渠道只给远程引用(无内联字节)时同样诚实,不静默;
// - 摄取在**闸门之后**:被拉黑/被隔离的消息绝不在盘上留下文件,其附件也只进
// 隔离会话,不镜像到主人主会话;
// - 只有附件的消息(无文本)不再被判成「没有内容」而丢弃;
// - 图片类媒体保持现状(不走摄取,也不落库)。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// stubModelAdapter 是最小的 model.Adapter:记下每次补全请求的**模型可见视图**
// (即装配期 resolveDocuments 替换之后的形态),并回一条固定文本。
type stubModelAdapter struct {
mu sync.Mutex
seen [][]model.Message
}
func (a *stubModelAdapter) Name() string { return "stub" }
func (a *stubModelAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.mu.Lock()
a.seen = append(a.seen, req.Messages)
a.mu.Unlock()
return model.Response{Model: "stub", Content: "读完了", StopReason: "stop"}, nil
}
func (a *stubModelAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return a.Chat(ctx, req, model.Options{})
}
// visibleText 返回模型最后看到的全部消息文本(含系统与用户回合)。
func (a *stubModelAdapter) visibleText() string {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.seen) == 0 {
return ""
}
var b strings.Builder
for _, m := range a.seen[len(a.seen)-1] {
for _, c := range m.Content {
b.WriteString(c.Text)
b.WriteString("\n")
}
}
return b.String()
}
// calls 返回模型被调用的次数(0 = 循环根本没跑)。
func (a *stubModelAdapter) calls() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.seen)
}
// safeBuffer 是并发安全的日志缓冲(桥接器的入站路径可能在 goroutine 里写日志)。
type safeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *safeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *safeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// ingestEnv 是入站附件端到端用例的装配:**真 store + 真抽取器 + 真循环 + 假模型**,
// 因此模型看到的清单行与正文就是生产路径装配出来的那一份。
type ingestEnv struct {
bridge *Bridge
model *stubModelAdapter
adapter *fakeAdapter
sessions *stubSessions
uploads string
logs *safeBuffer
}
// newIngestEnv 装配上述端到端环境。cfg 给出入站附件的落盘配置(未给 UploadsDir 时
// 落到临时目录);cfg 为零值时即「未接线」用例(跳过 SetDocumentStore)。
func newIngestEnv(t *testing.T, cfg documents.StoreConfig, wireStore bool) *ingestEnv {
t.Helper()
dataDir := t.TempDir()
uploads := cfg.UploadsDir
if uploads == "" {
uploads = filepath.Join(dataDir, "uploads", "documents")
}
cfg.UploadsDir = uploads
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
modelAdapter := &stubModelAdapter{}
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
resolver := documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: log})
runner := loop.New(loop.Options{
Agent: ag, DocumentResolver: resolver, MaxRounds: 4, Log: log,
})
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
opts := Options{
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
}
if wireStore {
opts.DocumentStore = cfg
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
platform := &fakeAdapter{name: "telegram"}
b.Add(platform)
return &ingestEnv{
bridge: b, model: modelAdapter, adapter: platform,
sessions: sess, uploads: uploads, logs: logs,
}
}
// fileMessage 构造一条主人私聊消息,附带一个文件类媒体。
func fileMessage(text, name string, data []byte, mime string) adapter.UnifiedMessage {
msg := directMessage()
msg.Content = text
msg.ContentType = adapter.ContentText
if text == "" {
msg.ContentType = adapter.ContentFile
}
msg.Media = []adapter.Media{{
Kind: adapter.MediaFile, Name: name, Size: int64(len(data)), MIMEType: mime, Data: data,
}}
return msg
}
// docDirNames 返回文档落盘目录里的条目名(目录不存在时返回 nil)。
func docDirNames(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("读文档目录: %v", err)
}
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// TestInboundFileMediaIngestedAndVisibleToModel 覆盖验收第 3 条:一条带文件媒体的
// 入站消息 → 落库 → 抽取 → **模型可见视图里出现该附件的清单行与正文**。
func TestInboundFileMediaIngestedAndVisibleToModel(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
body := "第一行:昔涟的测试附件\n第二行:正文必须真的进上下文\n"
msg := fileMessage("看看这个", "notes.txt", []byte(body), "text/plain")
env.bridge.process(context.Background(), msg)
names := docDirNames(t, env.uploads)
if len(names) != 1 {
t.Fatalf("落盘文件数 = %v, want 1(附件必须落库)", names)
}
stored, err := os.ReadFile(filepath.Join(env.uploads, names[0]))
if err != nil {
t.Fatalf("读落盘文件: %v", err)
}
if string(stored) != body {
t.Fatalf("落盘内容 = %q, want %q", stored, body)
}
if !strings.HasSuffix(names[0], ".txt") || len(names[0]) != documents.HashHexLen+len(".txt") {
t.Fatalf("落盘名 = %q, want <32 位十六进制>.txt(内容寻址)", names[0])
}
visible := env.model.visibleText()
if !strings.Contains(visible, "[附件 "+names[0]+" · ") {
t.Fatalf("模型可见视图里没有附件清单行:%q", visible)
}
if !strings.Contains(visible, "第二行:正文必须真的进上下文") {
t.Fatalf("模型可见视图里没有附件正文:%q", visible)
}
if !strings.Contains(visible, "看看这个") {
t.Fatalf("模型可见视图里丢了用户文本:%q", visible)
}
if log := env.logs.String(); !strings.Contains(log, "inbound attachment stored") ||
!strings.Contains(log, "source=services/channelbridge") {
t.Fatalf("落库日志缺少来源或内容:%q", log)
}
}
// TestInboundUnsupportedFileIsHonestAndNotStored 覆盖验收第 4 条:不支持的格式
// 不落库,但模型知道「有附件读不到」,且日志带来源、只记 name/mime/size(绝不记正文)。
func TestInboundUnsupportedFileIsHonestAndNotStored(t *testing.T) {
t.Parallel()
cases := []struct {
label string
name string
mime string
data []byte
}{
{"扩展名不在白名单", "tool.py", "text/x-python", []byte("print('秘密内容')\n")},
{"内容被嗅探否决", "lie.txt", "text/plain", append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 32)...)},
}
for _, tc := range cases {
env := newIngestEnv(t, documents.StoreConfig{}, true)
env.bridge.process(context.Background(), fileMessage("跑一下", tc.name, tc.data, tc.mime))
if names := docDirNames(t, env.uploads); len(names) != 0 {
t.Fatalf("%s: 被拒的附件仍落库 %v", tc.label, names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, tc.name) || !strings.Contains(visible, "无法读取") {
t.Fatalf("%s: 模型可见视图里没有诚实提示行:%q", tc.label, visible)
}
if env.model.calls() == 0 {
t.Fatalf("%s: 回合没有跑到模型", tc.label)
}
log := env.logs.String()
if !strings.Contains(log, "inbound attachment rejected") || !strings.Contains(log, tc.name) {
t.Fatalf("%s: 缺少带来源的拒绝日志:%q", tc.label, log)
}
if strings.Contains(log, "秘密内容") {
t.Fatalf("%s: 日志里出现了附件正文(红线:只记 name/mime/size)", tc.label)
}
}
}
// TestInboundFileMediaBlacklistedNotIngested 覆盖验收第 5 条的一半:被黑白名单拉黑的
// 群/陌生人消息,附件**不落库**、也不驱动循环(摄取在闸门之后)。
func TestInboundFileMediaBlacklistedNotIngested(t *testing.T) {
t.Parallel()
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
var rejected []string
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
"bot_token": "configured", "denyGroups": []string{"-100"},
}}
b.Add(ad)
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-100", ChannelType: adapter.ChannelGroup,
GroupID: "-100", SenderID: "9", SenderName: "陌生人",
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "给你个文件",
ContentType: adapter.ContentText,
Media: []adapter.Media{{
Kind: adapter.MediaFile, Name: "stranger.txt", Size: 6, MIMEType: "text/plain", Data: []byte("你好\n"),
}},
}
b.process(context.Background(), msg)
if names := docDirNames(t, uploads); len(names) != 0 {
t.Fatalf("被拉黑的消息把附件落库了:%v", names)
}
if chat.sessionID != "" || chat.messages != nil {
t.Fatalf("被拉黑的消息仍驱动了循环:session=%q", chat.sessionID)
}
if len(rejected) != 1 || rejected[0] != "group_denied" {
t.Fatalf("拒绝原因 = %v, want [group_denied]", rejected)
}
}
// TestInboundGroupFileStaysInIsolatedSession 覆盖验收第 5 条的另一半:第三方(群聊)
// 消息的附件只随该消息进入**隔离会话**,绝不镜像到主人主会话(沿用既有策略测试写法)。
func TestInboundGroupFileStaysInIsolatedSession(t *testing.T) {
t.Parallel()
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 被点名的群消息 → 回复(design/12 §6 策略二),因此会真的走完流水线。
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
Content: "看下这个", ContentType: adapter.ContentText,
Media: []adapter.Media{{
Kind: adapter.MediaFile, Name: "third.txt", Size: 4, MIMEType: "text/plain", Data: []byte("hi\n"),
}},
}
b.process(context.Background(), msg)
if names := docDirNames(t, uploads); len(names) != 1 {
t.Fatalf("群聊附件落盘文件数 = %v, want 1(第三方附件同样走共享存储)", names)
}
if chat.sessionID != "sess-g" {
t.Fatalf("循环会话 = %q, want 隔离会话 sess-g", chat.sessionID)
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("隔离会话落库条数 = %d, want 2", len(recs))
}
for i, rec := range recs {
if rec.SessionID != "sess-g" {
t.Fatalf("record[%d] SessionID = %q, want 隔离会话(绝不镜像到主人主会话)", i, rec.SessionID)
}
}
// 用户消息在循环里带着文档部件(图片/文档只进用户回合)。
var docParts int
for _, m := range chat.messages {
if m.Role == model.RoleSystem {
continue
}
for _, c := range m.Content {
if c.Type == model.ContentTypeDocument {
docParts++
}
}
}
if docParts != 1 {
t.Fatalf("用户消息里的文档部件数 = %d, want 1", docParts)
}
}
// TestInboundFileOnlyMessageReachesLoop 钉住「只有附件、没有文本」的消息不再被判成
// 没有内容而丢弃,且那条消息**不塞空文本部件**(provider 会拒空文本块)。
func TestInboundFileOnlyMessageReachesLoop(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
msg := fileMessage("", "only.md", []byte("# 只有附件\n正文\n"), "text/markdown")
env.bridge.process(context.Background(), msg)
if env.model.calls() == 0 {
t.Fatal("纯附件消息没有驱动循环")
}
names := docDirNames(t, env.uploads)
if len(names) != 1 {
t.Fatalf("落盘文件数 = %v, want 1", names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, "[附件 "+names[0]+" · ") || !strings.Contains(visible, "# 只有附件") {
t.Fatalf("模型可见视图里没有清单行或正文:%q", visible)
}
env.model.mu.Lock()
defer env.model.mu.Unlock()
if len(env.model.seen) == 0 {
t.Fatal("没有记录到模型请求")
}
for _, m := range env.model.seen[0] {
if m.Role != model.RoleUser {
continue
}
for _, c := range m.Content {
if c.Type == model.ContentTypeText && strings.TrimSpace(c.Text) == "" {
t.Fatalf("纯附件消息里塞了空文本部件:%+v", m.Content)
}
}
}
}