主人 2026-10-13 裁决「乙 C」= 默认按甲(群里不对她说就不开口)+ 可调旋钮。 - inbound.go: owner 不再无条件短路参与闸(去掉 `!actor.IsOwner()` 门), 主人群消息改走 shouldRespond 的 owner 车道分支;主人私聊恒回。 - ownerlane.go(新): owner 车道结构化闸 + 判据 `msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)` (未接线渠道退回旧判据,保护 kook/webhook 的群 @);指向她本人必回(硬保留); 非指向消息按每群旋钮——默认只进上下文不回(registerSilenced 落隔离会话)。 「少说点」类关键词落成结构化降频配置,带 source 日志。 - channelbridge.go: Options.OwnerLaneFor/OwnerLaneSet 读写接缝 + Bridge 状态。 - engage.go: 冷避词表并入「少说点」(与既有「少说两句」同义)。 - runtimeconfig/channels_ownerlane.go(新): owner_lane.json 落盘面 (默认档 + 每群覆盖,可读可改,面板可写)。 - wire_channels.go: 注入 owner_lane.json 存储($DATA_DIR/config/)。 - 测试:不对她说就不回(进上下文零回复)/ @她必回(即使降频)/ 未接线退回 旧判据 / 降频生效且可恢复 + 频率闸 / 旋钮读写接缝 / 归属不变量闭合 / 陌生人私聊与主人私聊回归。变异自证:恢复 owner 短路 ⇒ 5 条用例红。
258 lines
9.5 KiB
Go
258 lines
9.5 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"
|
||
)
|
||
|
||
// 本文件从渠道侧覆盖 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)
|
||
}
|
||
}
|