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 交叉编译
This commit is contained in:
1 parent
3732c538cc
commit
5969bba3bd
22 files changed
+1396
-23
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user