Files
AskaEth 537a8b7988 feat(channel): owner 车道结构化参与闸与可调旋钮(乙 = C)
主人 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 条用例红。
2026-10-13 20:48:05 +08:00

258 lines
9.5 KiB
Go
Raw Permalink 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 读取它)、目标会话 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)
}
}