fix(channelbridge): 成员车道「指向她」判据改为 AddressedToMe(@ 别人不再必回)

ruleLightJudge 原判据是 len(msg.Mentions) > 0 —— 只要有任意 @ 就必回,
@ 的是谁不论。2026-10-14 真机病灶:群里 @3889001246、@867587435 两条
都不是 @ 她,她仍逐条回。

改为复用 owner 车道已验证的同一份判据 ownerAddressedToMe
(msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)):
- 已接线渠道(qq-obv11 填了 SelfID/MentionAll/AddressedToMe):只有 @ 她本人 /
  @全体 才必回,@ 别人降级为无结论 → 参与度触发/概率那一档(未触发只登记);
- 未接线渠道(kook/webhook/telegram/weixin_ilink 未填 SelfID):逐字不变,
  仍按旧判据必回(「没有信号」≠「没在对我说话」);
- 判据全部来自结构化字段,绝不匹配正文里的 @<数字>。

口径依据 design/18 §2 触发① 的 2026-10-13 修订(消费点在 M13 只接了 owner 车道)。
This commit is contained in:
AskaEth committed 2026-10-14 07:21:07 +08:00
1 parent 0c2322bb92
commit 5969e700e1
2 files changed
+152 -7

No files matched your search

@@ -110,6 +110,138 @@ func TestNonOwnerMentionResponds(t *testing.T) {
}
}
// memberLaneBridge 构建一条非主人群消息用的桥接器:主人档案为空(不命中白名单 ⇒
// 走非主人分级),未装配参与度引擎与 LightJudge ⇒ 规则得不出结论时失败关闭为静默,
// 使「必不必回」只由规则判据决定,用例因此确定。
func memberLaneBridge(t *testing.T, chat *stubChatter, hist *captureHistory) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b
}
// TestMemberLaneAddressedToHerCriterion 钉住 design/18 §2 触发①(2026-10-13 修订)
// 在**成员车道**上的落地。2026-10-14 真机报障:群员互相 @ 的消息也被她必回
// (`@3889001246`、`@867587435` 两条),病灶就是 `ruleLightJudge` 拿
// 「`Mentions` 非空」当「在叫她」。判据现在与 owner 车道同一份
// (`ownerAddressedToMe`):**指向她本人**(@她 / @全体)才必回。
//
// 「必不必回」用「完整循环有没有被驱动」(chat.sessionID)判定;
// 「不回」还必须是**登记但不回**(历史里恰好一条 user 记录),而不是消息被丢掉。
//
// 变异自证(把判据换回旧的 `if len(msg.Mentions) > 0`):
// - 「@ 别人」一例立刻变红(旧实现把 @ 别人也当成在叫她,循环被驱动);
// - 「AddressedToMe 已置位、提及表为空」一例同样变红(旧实现只认提及表,
// 结构化字段被无视 ⇒ @全体 那种「字段到位但 at 段没落表」的情形丢掉必回)。
func TestMemberLaneAddressedToHerCriterion(t *testing.T) {
t.Parallel()
const selfID = "3443115524" // 她自己的 self_id(2026-10-14 报障现场的同一个号)
cases := []struct {
name string
msg func() adapter.UnifiedMessage
wantReply bool
wantRecords int
}{
{
name: "mentioning someone else (wired) is not addressed to her",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("/今日猪猪", adapter.Mention{UserID: "3889001246"})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf() // @ 的不是她 ⇒ false
return m
},
wantReply: false,
wantRecords: 1,
},
{
name: "addressed to her by name (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@她 在吗", adapter.Mention{UserID: selfID})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
name: "mention all (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 都来看一眼", adapter.Mention{UserID: "all"})
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
// 结构化字段是**主判据**:@全体 的标记到位而 at 段没落进提及表时,
// AddressedToMe 仍为 true,必回语义不得丢。
name: "addressed flag set without mention list still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 集合")
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
hist := &captureHistory{}
b := memberLaneBridge(t, chat, hist)
b.process(context.Background(), tc.msg())
if got := chat.sessionID != ""; got != tc.wantReply {
t.Fatalf("循环被驱动 = %v, want %v(session=%q)", got, tc.wantReply, chat.sessionID)
}
if !tc.wantReply && len(hist.records()) != tc.wantRecords {
t.Fatalf("records = %d, want %d(只登记不回,不是丢掉)", len(hist.records()), tc.wantRecords)
}
})
}
}
// TestMemberLaneUnwiredChannelKeepsMentionFallback 钉住判据的后半句在**成员车道**上
// 同样成立:未接线渠道(SelfID 为空 ⇒ AddressedToMe 恒 false)上的群 @ 按**旧判据**
// 必回。「没有信号」绝不能被当成「没在对我说话」——否则 kook / webhook 等尚未填
// SelfID 的平台,群 @ 会被一并打死。这是 owner 车道 M11 reviewer 给死的写法,
// 本片只是把它接到成员车道上。
func TestMemberLaneUnwiredChannelKeepsMentionFallback(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
b := memberLaneBridge(t, chat, &captureHistory{})
m := nonOwnerGroupMsg("@别人 你好", adapter.Mention{UserID: "someone-else"})
// SelfID 留空 = 未接线 ⇒ AddressedToMe 恒 false(fail-closed)。
if m.AddressedToMe {
t.Fatalf("未接线渠道的 AddressedToMe = true, want false(fail-closed)")
}
b.process(context.Background(), m)
if chat.sessionID != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
}
}
func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
t.Parallel()