feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)
agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)
实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
This commit is contained in:
1 parent
37e98ec584
commit
9c2a37c4ac
32 files changed
+1768
-136
No files matched your search
@@ -2,6 +2,7 @@ package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
type fakeAdapter struct {
|
||||
name string
|
||||
connected bool
|
||||
cfg map[string]any
|
||||
sends []adapter.UnifiedMessage
|
||||
handler adapter.MessageHandler
|
||||
sendErr error
|
||||
@@ -25,6 +27,9 @@ type fakeAdapter struct {
|
||||
|
||||
func (f *fakeAdapter) Name() string { return f.name }
|
||||
func (f *fakeAdapter) Config() map[string]any {
|
||||
if f.cfg != nil {
|
||||
return f.cfg
|
||||
}
|
||||
return map[string]any{"bot_token": "configured"}
|
||||
}
|
||||
func (f *fakeAdapter) Connect(context.Context) error { f.connected = true; return nil }
|
||||
@@ -236,6 +241,9 @@ func (c *captureHistory) List(context.Context, string, int64, int, string) ([]hi
|
||||
func (c *captureHistory) Recent(context.Context, string, int) ([]history.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
|
||||
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
|
||||
return "", nil
|
||||
@@ -350,16 +358,177 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// Group chat stays isolated: nothing is written to the main web session and the
|
||||
// loop runs against the isolated session id.
|
||||
if recs := hist.records(); len(recs) != 0 {
|
||||
t.Fatalf("group appends = %+v, want none persisted", recs)
|
||||
// design/16 §4.6: a group/stranger conversation stays isolated — it is NOT
|
||||
// persisted into the main web session, and the loop runs against the isolated
|
||||
// session id. Messages land only in the isolated session's own platform log
|
||||
// (the adapter log page), so nothing reaches the Web main session.
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("group appends = %d, want 2 (isolated user + assistant)", len(recs))
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if rec.SessionID != "sess-g" {
|
||||
t.Fatalf("record[%d] SessionID = %q, want isolated sess-g (not web)", i, rec.SessionID)
|
||||
}
|
||||
if rec.Platform != "telegram" {
|
||||
t.Fatalf("record[%d] Platform = %q, want telegram", i, rec.Platform)
|
||||
}
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
|
||||
// A stranger's group session that would otherwise be processed.
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
||||
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
|
||||
"bot_token": "configured",
|
||||
"denyGroups": []string{"-100"},
|
||||
}}
|
||||
var rejected []string
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
||||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
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: "spam",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// The blacklisted group message never reaches the agent loop and never sends a
|
||||
// reply; the rejection reason is surfaced for the audit trail.
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
if len(ad.sends) != 0 {
|
||||
t.Fatalf("blacklisted message sent %d replies, want 0", len(ad.sends))
|
||||
}
|
||||
if len(rejected) != 1 || rejected[0] != "group_denied" {
|
||||
t.Fatalf("rejection reasons = %v, want [group_denied]", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}}
|
||||
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
|
||||
"bot_token": "configured",
|
||||
"allowUsers": []string{"111"},
|
||||
}}
|
||||
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{}})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "200", ChannelType: adapter.ChannelDirect,
|
||||
SenderID: "200", Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hi",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// A direct message from a user NOT in the allow whitelist is dropped; the owner
|
||||
// is never affected (this is a stranger).
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
if len(ad.sends) != 0 {
|
||||
t.Fatalf("non-whitelisted user sent %d replies, want 0", len(ad.sends))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hello all",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// The loop sees a leading third-party boundary system message plus the user
|
||||
// message already prefixed with [第三方 · 张三] (the raw content is preserved in
|
||||
// history, so no source data is lost).
|
||||
if len(chat.messages) != 2 {
|
||||
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
|
||||
}
|
||||
if chat.messages[0].Role != model.RoleSystem || !strings.Contains(chat.messages[0].Content[0].Text, "非管理员账户") {
|
||||
t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0])
|
||||
}
|
||||
user := chat.messages[1]
|
||||
if user.SenderName != "张三" || user.Content[0].Text != "[第三方 · 张三] hello all" {
|
||||
t.Fatalf("loop user message = %+v, want SenderName 张三 and [第三方 · 张三] prefix", user)
|
||||
}
|
||||
// History keeps the RAW content and carries the sender name so the adapter log
|
||||
// page can label the stream without altering the source message.
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 || recs[0].Content != "hello all" || recs[0].SenderName != "张三" {
|
||||
t.Fatalf("isolated records = %+v, want raw content with SenderName 张三", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
||||
// Owner speaking inside a group: the session stays restricted (mem read-only),
|
||||
// but the owner is never labeled as a third party (design/16 §4.6 §1-2).
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", SenderID: "owner_uid", SenderName: "主人",
|
||||
Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里",
|
||||
ContentType: adapter.ContentText,
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// Restricted context → a third-party boundary system message is still injected.
|
||||
if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem {
|
||||
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
|
||||
}
|
||||
// Owner's message is NOT prefixed as third-party and carries no SenderName.
|
||||
user := chat.messages[1]
|
||||
if user.SenderName != "" || user.Content[0].Text != "我在群里" {
|
||||
t.Fatalf("owner group message = %+v, want no SenderName and no [第三方] prefix", user)
|
||||
}
|
||||
// Owner group speech still lands in the isolated session (not the main web session).
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 || recs[0].SessionID != "sess-g" {
|
||||
t.Fatalf("owner group records = %+v, want isolated sess-g, not web", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A bridge built without a history store persists nothing but still runs/replies.
|
||||
|
||||
Reference in new issue
Block a user