Files
Cyrene_v2/internal/services/channelbridge/persona_test.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

250 lines
9.2 KiB
Go

package channelbridge
import (
"context"
"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/history"
"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"
)
// This file covers design/22 §2/§7-3/§7-4 from the channel side: which role an
// inbound IM turn answers as. The chain asserted is the structural session → the
// resolved SessionEnv the loop is driven with → the role named in it.
// personaHistory is the bridge's history store with one bound session role, so the
// owner-private mirror can be asserted to follow the main session's role.
type personaHistory struct {
*captureHistory
personas map[string]string
}
func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (string, error) {
return p.personas[sessionID], nil
}
// envChatter is a Chatter that records the SessionEnv the bridge resolved for the
// turn, which is where the role travels (the loop reads it from ctx).
type envChatter struct {
env loop.SessionEnv
got bool
}
func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
e.env = loop.SessionEnvFrom(ctx)
e.got = true
return &loop.Result{Text: "在呢"}, nil
}
// personaBridge builds a started bridge whose single telegram account is bound to
// channelPersona, over a history store that binds mainSessionPersona to the main
// session ("" = unbound).
func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Bridge, *envChatter) {
t.Helper()
chat := &envChatter{}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: mainSessionPersona}},
ChannelPersona: func(platform, accountID string) string { return channelPersona },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
return b, chat
}
// TestInboundOwnerPrivateFollowsMainSessionRole is design/22 §7-3: an owner
// private IM chat is mirrored into the main web session, so it must answer as the
// role that session is bound to — not as the global default and not as the
// account's channel-level role.
func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, "xilian", "yuki")
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven for the owner private message")
}
if chat.env.PersonaID != "xilian" {
t.Fatalf("resolved persona = %q, want the main session's binding (xilian)", chat.env.PersonaID)
}
// The session binding wins, so the channel binding is never reached here.
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got)
}
}
// TestInboundChannelPersonaPrecedence pins the three-state precedence of design/22
// §2: the channel-level binding applies only when neither the session nor anything
// stronger resolves a role.
func TestInboundChannelPersonaPrecedence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mainSession string
channel string
want string
}{
{name: "session binding wins", mainSession: "xilian", channel: "yuki", want: "xilian"},
{name: "channel binding when session unbound", mainSession: "", channel: "yuki", want: "yuki"},
{name: "default when neither binds", mainSession: "", channel: "", want: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, tc.mainSession, tc.channel)
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != tc.want {
t.Fatalf("resolved persona = %q, want %q", chat.env.PersonaID, tc.want)
}
})
}
}
// TestInboundGroupForcesDefaultRole is design/22 §7-4: a group conversation — even
// the owner's own messages inside it — keeps the default role even when the account
// binds one, so the isolated group session can never be moved into a role's scope.
func TestInboundGroupForcesDefaultRole(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
b.process(context.Background(), adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20",
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText,
})
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != "" {
t.Fatalf("group conversation persona = %q, want the default role (empty)", chat.env.PersonaID)
}
// The isolation boundary itself is untouched by role resolution.
if chat.env.MemWritable {
t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env)
}
}
// TestResolvePersonaStrangerUsesDefaultRole covers the other restricted lane
// directly: a stranger's isolated private conversation is not the owner's mirror,
// so both the session binding and the channel binding are skipped (design/22 §7-4).
// The pipeline path is asserted for groups above; a stranger message only reaches
// the loop when the participation gate admits it (design/12 §6), which is
// orthogonal to role resolution and asserted elsewhere.
func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) {
t.Parallel()
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
stranger := session.Session{SessionID: "sess-s", Owner: false, MemWritable: false}
target := inboundTargetFor(stranger, "telegram")
if target.private {
t.Fatalf("stranger target = %+v, want an isolated (non-mirrored) session", target)
}
if got := b.resolvePersona(context.Background(), stranger, target, "telegram"); got != "" {
t.Fatalf("stranger persona = %q, want the default role (empty)", got)
}
}
// TestChannelPersonaLookupSplitsAccountName proves the account-level lookup: a
// multi-account platform resolves the role of the account that received the
// message (platform:confId), not of the platform as a whole.
func TestChannelPersonaLookupSplitsAccountName(t *testing.T) {
t.Parallel()
var gotPlatform, gotAccount string
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string {
gotPlatform, gotAccount = platform, accountID
if accountID == "conf0" {
return "yuki"
}
return ""
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram:conf0"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if gotPlatform != "telegram" || gotAccount != "conf0" {
t.Fatalf("lookup = %s/%s, want telegram/conf0", gotPlatform, gotAccount)
}
if got := b.personaOfAdapter("telegram:conf0"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
if got := b.personaOfAdapter("telegram:conf1"); got != "" {
t.Fatalf("unbound account persona = %q, want empty", got)
}
}
// TestRemovedAccountForgetsChannelPersona locks the reload contract: an account
// dropped by a reload must not keep answering as its old channel role.
func TestRemovedAccountForgetsChannelPersona(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
b.removeAdapter(ad, nil)
if got := b.personaOfAdapter("telegram"); got != "" {
t.Fatalf("cached persona after removal = %q, want empty", got)
}
}