From 5969e700e106a426c86cbb5ebbf2fa984bc04174 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 14 Oct 2026 07:21:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(channelbridge):=20=E6=88=90=E5=91=98?= =?UTF-8?q?=E8=BD=A6=E9=81=93=E3=80=8C=E6=8C=87=E5=90=91=E5=A5=B9=E3=80=8D?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E6=94=B9=E4=B8=BA=20AddressedToMe=EF=BC=88@?= =?UTF-8?q?=20=E5=88=AB=E4=BA=BA=E4=B8=8D=E5=86=8D=E5=BF=85=E5=9B=9E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 车道)。 --- internal/services/channelbridge/light.go | 27 +++- internal/services/channelbridge/light_test.go | 132 ++++++++++++++++++ 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 59ef9e8..8ebe8cd 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -131,15 +131,27 @@ func hasHighValueKeyword(text string) bool { } // ruleLightJudge 只依据廉价规则信号判断是否值得回复 -// (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示 -// 名、高价值关键词;除此之外一律无结论(交给参与度引擎 / +// (设计/12 §6 策略二)。优先级:**指向她本人**(design/18 §2 触发①)、 +// 提及主人的显示名、高价值关键词;除此之外一律无结论(交给参与度引擎 / // 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是 // 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断 // 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给 // 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不 // 推断身份(身份由 actor 元数据在会话层解析,红线)。 +// +// 触发①的判据与 owner 车道**同一份**(ownerAddressedToMe,见 ownerlane.go, +// 口径写死在 design/18 §2 触发① 的 2026-10-13 修订里):`@ 她本人 / @全体` +// 才必回,**不是「有任意提及」** —— 群里 @ 别人与 @ 她长得一模一样, +// 拿「Mentions 非空」当判据会让群员之间的互 @ 把她拽进每一段对话 +// (2026-10-14 真机病灶:`@3889001246`、`@867587435` 两条都被必回)。 +// +// 判据的后半句(SelfID 为空时退回「有提及」)**不可省**:kook / webhook 等尚未 +// 填 SelfID 的平台 AddressedToMe 恒 false,只留前半句会把它们的群 @ 一并打死 +// (「没有信号」≠「没在对我说话」)。反过来,判据**全部来自结构化字段**, +// 绝不匹配正文里的 "@<数字>" —— @ 她与 @ 别人在正文里长得一模一样, +// 那正是本病灶的成因。 func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict { - if len(msg.Mentions) > 0 { + if ownerAddressedToMe(msg) { return verdictRespond } if b.owner != nil && b.owner.DisplayName != "" && @@ -201,10 +213,11 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, return true, "stranger private chat: normal reply" } - // 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或 - // 高价值关键词一律直接回;规则得不出结论的部分交给参与度 - // 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传, - // 规则层不再因为它静默(裁决②)。 + // 策略二/五:基于规则的轻量判定。**指向她本人**(@她 / @全体)、提及主人 + // 显示名或高价值关键词一律直接回;规则得不出结论的部分交给参与度 + // 引擎 / 轻量判定器 —— 群里 @ 别人的消息因此不会必回,而是落到 + // 「参与度触发 / 概率」那一档(未触发则只登记)。**安静时段只作为信号**(inQuiet) + // 继续往下传,规则层不再因为它静默(裁决②)。 inQuiet := b.inQuietHours(b.now()) if b.ruleLightJudge(msg) == verdictRespond { return true, "rule signal: worth responding" diff --git a/internal/services/channelbridge/light_test.go b/internal/services/channelbridge/light_test.go index 657f722..57d8660 100644 --- a/internal/services/channelbridge/light_test.go +++ b/internal/services/channelbridge/light_test.go @@ -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()