- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
250 lines
9.2 KiB
Go
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: 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)
|
|
}
|
|
}
|