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" ) // 本文件从渠道侧覆盖 design/22 §2/§7-3/§7-4:一轮入站 IM 对话 // 以哪个角色作答。断言的链路是「结构化会话 → 驱动循环所用的 // 已解析 SessionEnv → 其中指定的角色」。 // personaHistory 是桥接器的历史存储,绑定了一个会话角色, // 以便断言主人私聊的镜像会跟随主会话的角色。 type personaHistory struct { *captureHistory personas map[string]string } func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (string, error) { return p.personas[sessionID], nil } // envChatter 是一个 Chatter,记录桥接器为该轮解析出的 SessionEnv(角色正是经由它传递, // 循环从 ctx 读取它)、目标会话 id 与模型可见的消息列表。 type envChatter struct { env loop.SessionEnv got bool sessionID string msgs []model.Message } func (e *envChatter) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) { e.env = loop.SessionEnvFrom(ctx) e.sessionID = sessionID e.msgs = msgs e.got = true return &loop.Result{Text: "在呢"}, nil } // personaBridge 构建一个已启动的桥接器:其唯一的 telegram 账号绑定到 // channelPersona,底层历史存储把 mainSessionPersona 绑定到主 // 会话("" = 未绑定)。 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: testOwnerProfile("telegram", "100"), 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 是 design/22 §7-3:主人 // 私聊 IM 被镜像进 Web 主会话,因此它必须以该会话绑定的 // 角色作答——既不是全局默认角色,也不是 // 账号的渠道级角色。 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) } // 会话绑定胜出,因此这里绝不会走到渠道绑定。 if got := b.personaOfAdapter("telegram"); got != "yuki" { t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got) } } // TestInboundChannelPersonaPrecedence 钉住 design/22 §2 的三态 // 优先级:只有会话以及其他更强的来源都解析不出角色时, // 渠道级绑定才生效。 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 是 design/22 §7-4:群聊——即使 // 群里是主人自己的消息——即使账号绑定了角色也仍用默认 // 角色,因此隔离的群会话绝不会被移入某个角色的作用域。 // 这条主人群消息**指向她本人**(含提及):owner 车道默认「不对她说就不开口」 // (design/18 §11),不带指向信号的主人群消息只进上下文、不驱动回合, // 因此这里刻意带上 @ 信号,让本用例仍只验证角色解析这件事。 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: testOwnerProfile("telegram", "100"), 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}, Mentions: []adapter.Mention{{UserID: "100"}}, 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) } // 隔离边界本身不受角色解析影响。 if chat.env.MemWritable { t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env) } } // TestResolvePersonaStrangerUsesDefaultRole 直接覆盖另一条受限通道: // 陌生人的隔离私聊不是主人的镜像, // 因此会话绑定与渠道绑定都被跳过(design/22 §7-4)。 // 群聊的流水线路径已在上文断言;陌生人消息只有在参与度 // 闸门放行时才会到达循环(design/12 §6),这与角色解析 // 正交,已在别处断言。 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 证明查询是按账号的: // 多账号平台解析的是接收到该消息的账号(platform:confId) // 的角色,而不是整个平台的角色。 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 锁定重载约定:被 // 重载移除的账号绝不能继续以它旧的渠道角色作答。 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) } }