Files
Cyrene_v2/internal/services/channelbridge/persona_test.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

250 lines
9.0 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
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 读取它)。
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 构建一个已启动的桥接器:其唯一的 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:群聊——即使
// 群里是主人自己的消息——即使账号绑定了角色也仍用默认
// 角色,因此隔离的群会话绝不会被移入某个角色的作用域。
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}, 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)
}
}