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: 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 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: 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) } // 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) } }