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
@@ -86,6 +86,11 @@ type Options struct {
|
||||
Owner *user.OwnerProfile
|
||||
History history.HistoryStore
|
||||
OnPersisted func(history.Message)
|
||||
// OnRejected, when non-nil, is called after a message is dropped by the
|
||||
// allow/deny black-or-white lists (design/16 §4.6 §6), so the composition root
|
||||
// can route it into an audit trail. It is invoked with the rejected message and
|
||||
// a short reason token ("group_denied", "user_not_allowed", ...). Nil = log only.
|
||||
OnRejected func(adapter.UnifiedMessage, string)
|
||||
}
|
||||
|
||||
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||
@@ -97,6 +102,7 @@ type Bridge struct {
|
||||
owner *user.OwnerProfile
|
||||
history history.HistoryStore
|
||||
onPersisted func(history.Message)
|
||||
onRejected func(adapter.UnifiedMessage, string)
|
||||
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
@@ -130,6 +136,7 @@ func New(opts Options) (*Bridge, error) {
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
onRejected: opts.OnRejected,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
cancel: func() {},
|
||||
@@ -205,7 +212,12 @@ func (b *Bridge) Start() {
|
||||
b.connected[a.Name()] = true
|
||||
b.mu.Unlock()
|
||||
|
||||
a.SetMessageHandler(b.onMessage)
|
||||
// Close over the adapter so the inbound callback knows which account
|
||||
// received the message — required for multi-account reply routing and for
|
||||
// reading that account's allow/deny lists (design/16 §4.6 §5).
|
||||
a.SetMessageHandler(func(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.onMessage(ctx, msg, a)
|
||||
})
|
||||
b.wg.Add(1)
|
||||
go b.readLoop(a)
|
||||
b.log.Info("channel connected", "channel", a.Name())
|
||||
@@ -274,7 +286,7 @@ func (b *Bridge) snapshot() []adapter.PlatformAdapter {
|
||||
// when no bot token is present.
|
||||
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||
cfg := a.Config()
|
||||
switch a.Name() {
|
||||
switch basePlatform(a.Name()) {
|
||||
case "telegram", "weixin-ilink":
|
||||
return cfgStr(cfg, "bot_token") != ""
|
||||
case "qq-obv11":
|
||||
@@ -290,6 +302,16 @@ func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// basePlatform returns the bare platform name of a (possibly multi-account)
|
||||
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
|
||||
// reply-routing fallback treat every account of a platform identically.
|
||||
func basePlatform(name string) string {
|
||||
if base, _, ok := strings.Cut(name, ":"); ok {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// readLoop runs an adapter's blocking read path until the bridge stops. The emit
|
||||
// sink is wired to onMessage so poll-style adapters (Telegram getUpdates, weixin
|
||||
// long poll) deliver through the same pipeline as push-style adapters.
|
||||
@@ -297,13 +319,16 @@ func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
|
||||
defer b.wg.Done()
|
||||
ctx := b.ctx
|
||||
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
|
||||
b.onMessage(ctx, msg)
|
||||
b.onMessage(ctx, msg, a)
|
||||
})
|
||||
}
|
||||
|
||||
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
|
||||
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
|
||||
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
// The adapter is carried explicitly so the pipeline knows which account received
|
||||
// the message (multi-account reply routing + per-account allow/deny, design/16
|
||||
// §4.6 §5).
|
||||
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
@@ -337,15 +362,24 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
go func() {
|
||||
defer b.wg.Done()
|
||||
defer func() { <-b.sem }()
|
||||
b.process(ctx, msg)
|
||||
b.processFrom(ctx, msg, a)
|
||||
}()
|
||||
}
|
||||
|
||||
// process runs the full inbound pipeline: resolve the session/actor, drive the
|
||||
// agent loop with the chat text, and send the loop's reply back through the
|
||||
// channel. Identity is resolved structurally by the session layer; the bridge
|
||||
// never reads message content to decide who the sender is.
|
||||
// process runs the inbound pipeline using the adapter whose platform matches the
|
||||
// message (single-account convenience path; the adapter may be a bare
|
||||
// platform-named registration).
|
||||
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.processFrom(ctx, msg, b.adapter(msg.Platform))
|
||||
}
|
||||
|
||||
// processFrom runs the full inbound pipeline against the named account adapter:
|
||||
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
|
||||
// agent loop with the chat text, and send the loop's reply back through the same
|
||||
// adapter that received the message. Identity is resolved structurally by the
|
||||
// session layer; the bridge never reads message content to decide who the sender
|
||||
// is (a project red line).
|
||||
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
b.log.Debug("inbound message has no chat text, skipped",
|
||||
@@ -353,6 +387,22 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
|
||||
// the pipeline, before a session is resolved or the loop is driven, so a
|
||||
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
|
||||
// owner is always allowed (structural, via the resolved actor).
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if actor.IsOwner() {
|
||||
// fall through: owner messages are never blacklisted
|
||||
} else if denied, reason := b.blacklisted(msg, a); denied {
|
||||
b.log.Warn("inbound message blacklisted, dropped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
|
||||
if b.onRejected != nil {
|
||||
b.onRejected(msg, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
|
||||
if err != nil {
|
||||
b.log.Warn("session resolve failed", "platform", msg.Platform,
|
||||
@@ -368,7 +418,7 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
|
||||
// main web session (shared across platforms) and persisted to chat_messages with
|
||||
// the channel platform, so the Web client mirrors it; group chats and strangers
|
||||
// keep their isolated sess-xxx context and are NOT persisted to the main session.
|
||||
// keep their isolated sess-xxx context and their own platform log.
|
||||
sessID := sess.SessionID
|
||||
isOwnerPrivate := !sess.IsGroup && sess.Owner
|
||||
platform := ""
|
||||
@@ -377,19 +427,26 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
platform = msg.Platform
|
||||
}
|
||||
|
||||
// Persist the inbound user message into the main web session before the turn, so
|
||||
// the Web client sees it even if the channel reply fails (design/16 §4.4).
|
||||
// The third-party boundary only applies in a restricted (group/stranger)
|
||||
// context: memory is read-only (MemWritable=false), and the sender name is
|
||||
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
|
||||
// resolved actor decides who is the owner vs a third party structurally — never
|
||||
// from prompt text.
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName)
|
||||
|
||||
// Persist the inbound user message before the turn so it is durable even if the
|
||||
// channel reply fails. Owner-private messages land in the main web session and
|
||||
// are broadcast to the Web client; restricted messages land in their isolated
|
||||
// session (the adapter log page) and are NOT broadcast to the main session.
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName)
|
||||
}
|
||||
|
||||
// Thread the resolved isolation boundary into the loop's memory gates: the
|
||||
// owner's private conversation reads+writes memory, group chats and strangers
|
||||
// are read-only isolation (design/02 §3, design/11 §2).
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, []model.Message{
|
||||
model.TextMessage(model.RoleUser, text),
|
||||
})
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
|
||||
if err != nil {
|
||||
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||
"session", sessID, "error", err)
|
||||
@@ -401,13 +458,14 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
// Persist the assistant reply into the main web session (design/16 §4.4) and
|
||||
// broadcast, then deliver it back to the channel — so the Web client mirrors the
|
||||
// IM reply too.
|
||||
// Persist the assistant reply (owner-private → main session + broadcast;
|
||||
// restricted → isolated log) then deliver it back to the channel.
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "")
|
||||
}
|
||||
if err := b.sendReply(ctx, msg, reply); err != nil {
|
||||
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
||||
if errors.Is(err, adapter.ErrUnsupported) {
|
||||
b.log.Debug("channel does not support outbound reply, skipped",
|
||||
"platform", msg.Platform, "session", sessID)
|
||||
@@ -417,11 +475,35 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendReply delivers the loop's text back to the channel's source conversation.
|
||||
// It mirrors the inbound message's channel targeting fields so the adapter
|
||||
// resolves the same peer the message came from.
|
||||
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, text string) error {
|
||||
a := b.adapter(in.Platform)
|
||||
// buildLoopMessages builds the model-visible message list for one inbound turn. In
|
||||
// a restricted (group/stranger) context it prepends a single system message that
|
||||
// states the third-party identity boundary — "every IM non-admin account's message
|
||||
// is a third party, unrelated to the owner" (design/16 §4.6 §1). The boundary is
|
||||
// decided structurally from the resolved SessionEnv, never inferred by the LLM.
|
||||
// The raw message text is left untouched (a copy is built with the prefix), so
|
||||
// persisted content stays consistent with what the source platform delivered.
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string) []model.Message {
|
||||
msg := model.TextMessage(model.RoleUser, text)
|
||||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||||
if thirdParty {
|
||||
msg.SenderName = senderName
|
||||
msg.Content[0].Text = fmt.Sprintf("[第三方 · %s] %s", senderName, text)
|
||||
}
|
||||
if !env.Restricted() {
|
||||
return []model.Message{msg}
|
||||
}
|
||||
boundary := model.TextMessage(model.RoleSystem, thirdPartyBoundaryPrompt)
|
||||
return []model.Message{boundary, msg}
|
||||
}
|
||||
|
||||
// sendReply delivers the loop's text back to the channel's source conversation
|
||||
// through the account adapter that received the message (multi-account safe,
|
||||
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
|
||||
// the adapter resolves the same peer the message came from.
|
||||
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
|
||||
if a == nil {
|
||||
a = b.adapter(in.Platform)
|
||||
}
|
||||
if a == nil {
|
||||
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||
}
|
||||
@@ -475,3 +557,103 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
}
|
||||
return msg.Platform + ":" + msg.ChannelID
|
||||
}
|
||||
|
||||
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
|
||||
// (group-chat / stranger) context so the model knows the identity boundary. It is
|
||||
// only injected when the session is structurally restricted (design/16 §4.6 §1);
|
||||
// the LLM never decides who is a third party — that was already resolved from the
|
||||
// actor metadata before this message exists.
|
||||
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
|
||||
|
||||
// senderDisplayName returns the human-readable sender name used to label a
|
||||
// third-party message. It prefers the resolved actor's display name (which the
|
||||
// channel layer derived from channel metadata + the raw SenderName), falling back
|
||||
// to the message's own SenderName, so an empty SenderName always means "the
|
||||
// owner/unlabeled" rather than a nickname that resolved to nothing.
|
||||
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
|
||||
if actor.DisplayName != "" {
|
||||
return actor.DisplayName
|
||||
}
|
||||
return msg.SenderName
|
||||
}
|
||||
|
||||
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
|
||||
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
|
||||
// against allowGroups/denyGroups; direct messages are matched by the sender's user
|
||||
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
|
||||
// deny list applies); a non-empty allow list becomes a white list. The owner is
|
||||
// never blacklisted (the caller skips this for owner messages).
|
||||
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
|
||||
if a == nil {
|
||||
return false, ""
|
||||
}
|
||||
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
|
||||
if msg.IsGroup() {
|
||||
if sliceContains(denyGroups, msg.GroupID) {
|
||||
return true, "group_denied"
|
||||
}
|
||||
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
|
||||
return true, "group_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
|
||||
if sliceContains(denyUsers, uid) {
|
||||
return true, "user_denied"
|
||||
}
|
||||
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
|
||||
return true, "user_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
|
||||
// The lists are stored as plain []string values (set by the multi-account wiring);
|
||||
// an absent key yields an empty list.
|
||||
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
|
||||
if cfg == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
|
||||
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
|
||||
}
|
||||
|
||||
// cfgStrings reads a []string field from an adapter config map, returning nil when
|
||||
// absent or of a different type.
|
||||
func cfgStrings(m map[string]any, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sliceContains reports whether s is present in list.
|
||||
func sliceContains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstNonEmptyStr returns a when non-empty, otherwise b.
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,8 @@ package channelbridge
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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/history"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,16 @@ func (b *Bridge) SetOnPersisted(fn func(history.Message)) {
|
||||
b.onPersisted = fn
|
||||
}
|
||||
|
||||
// SetOnRejected wires (or replaces) the hook called after a message is dropped by
|
||||
// the allow/deny black-or-white lists (design/16 §4.6 §6). The composition root
|
||||
// routes it into the gateway audit trail. It is a no-op-safe call and is safe for
|
||||
// concurrent use with in-flight processing.
|
||||
func (b *Bridge) SetOnRejected(fn func(adapter.UnifiedMessage, string)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.onRejected = fn
|
||||
}
|
||||
|
||||
// notifyPersisted hands a just-persisted message to the transport hook when one is
|
||||
// wired, so the gateway can broadcast `message_persisted` for cross-end sync.
|
||||
func (b *Bridge) notifyPersisted(msg history.Message) {
|
||||
@@ -53,3 +65,27 @@ func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, dev
|
||||
}
|
||||
b.notifyPersisted(full)
|
||||
}
|
||||
|
||||
// persistIsolated writes one message into a restricted (group / stranger) session's
|
||||
// own platform log (design/16 §4.6 §4): the message lands in the isolated sess-xxx
|
||||
// session (NOT the main web session) and carries the channel platform, the client
|
||||
// deviceID and the sender's display name, so the IM adapter log page can render the
|
||||
// non-owner message stream. It is deliberately NOT broadcast to the Web main session
|
||||
// (only owner-private messages mirror to the Web). It is a no-op when no history
|
||||
// store is wired, so the bridge runs unchanged without storage.
|
||||
func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID string, sess session.Session, platform, senderName string) {
|
||||
if b.history == nil {
|
||||
return
|
||||
}
|
||||
full, err := b.history.AppendFull(ctx, history.Message{
|
||||
SessionID: sess.SessionID, Role: role, Content: content, Platform: platform,
|
||||
DeviceID: deviceID, SenderName: senderName,
|
||||
})
|
||||
if err != nil {
|
||||
b.log.Warn("persist isolated session", "err", err, "role", role,
|
||||
"session", sess.SessionID, "platform", platform, "sender_name", senderName)
|
||||
return
|
||||
}
|
||||
// No broadcast: third-party messages must not reach the Web main session (design/16 §4.6 §3).
|
||||
_ = full
|
||||
}
|
||||
Reference in new issue
Block a user