From 91e4dd2fe12fc4b0e08faad34741cab972229c78 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 20:34:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(channel):=20=E9=9D=99=E9=BB=98=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E5=90=8C=E6=BA=90=E7=AC=AC=E4=B8=89=E6=96=B9=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=20=E2=80=94=E2=80=94=20=E6=97=A0=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E9=9D=9E=E4=B8=BB=E4=BA=BA=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AF=BB?= =?UTF-8?q?=E6=88=90=E4=B8=BB=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review p1:回帖路径已改用 thirdPartySenderName(非主人恒有标注:昵称 → actor uid → 「未署名发送者」),但 registerSilenced 仍用 senderDisplayName,非主人 无昵称时写出空串;静默行会被 transcript 读回她的上下文,而受限会话里空标注就是 「主人本人」⇒ 在 senderName 可选且无兜底的渠道(webhook)上,一条没昵称的陌生人 消息被渲染成「[发言归属] 以下发言来自主人(管理员账户)本人」。 - light.go:270:senderDisplayName → thirdPartySenderName,与回帖路径同源。 senderDisplayName 未成死代码:仍被 light.go:223(LightJudge 输入)、 engage.go:284、flowgate.go:183 使用。 - 新增 TestSilencedNamelessMemberIsNotReadAsTheOwner:走真链路(静默登记 → 落库 → 下一回合播种转录 → loop 归属渲染 → provider 请求),钉住「无昵称的非主人经 静默登记后,读回上下文时不得出现主人标注」。修前必红(写入口径与模型侧两条 断言同时报,含被读成主人的原样请求),修后绿。 验证:go build ./... / go vet ./... / go test -count=1 ./...(全树)全绿。 --- .../channelbridge/inbound_speaker_test.go | 68 +++++++++++++++++++ internal/services/channelbridge/light.go | 8 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/services/channelbridge/inbound_speaker_test.go b/internal/services/channelbridge/inbound_speaker_test.go index 2b486cb..80c55c7 100644 --- a/internal/services/channelbridge/inbound_speaker_test.go +++ b/internal/services/channelbridge/inbound_speaker_test.go @@ -60,6 +60,13 @@ func (c *captureModelAdapter) lastRequest(t *testing.T) model.Request { return c.reqs[len(c.reqs)-1] } +// count 返回已记录的 provider 请求数(0 表示循环没被驱动)。 +func (c *captureModelAdapter) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.reqs) +} + // groupLoopFor 构建一个群聊车道的真实循环(默认角色昔涟),使渠道侧的 // 端到端用例看到的正是生产装配的那条装配链(loop 的归属渲染在内)。 func groupLoopFor(t *testing.T, ad model.Adapter) *loop.Loop { @@ -260,6 +267,67 @@ func TestGroupTurnTellsTheModelWhoSaidWhat(t *testing.T) { t.Logf("模型侧实际形态:\n%s", requestDump(req)) } +// ownerNotePrefix 是主人本人边界说明的开头。断言只能认这个**带标记的 +// 完整前缀**:受限会话的角色声明里也含「来自主人本人」这类字面, +// 用「来自主人」做子串匹配会把它误当成主人说明。 +const ownerNotePrefix = "[发言归属] 以下发言来自主人(管理员账户)本人" + +// TestSilencedNamelessMemberIsNotReadAsTheOwner 覆盖 review p1 暴露的路径盲区: +// **静默登记**(registerSilenced:没 @ 她、也没有规则信号的群消息只登记不回) +// 落库的发送者标注,会被之后的回合经 transcript 读回她的上下文。受限会话里 +// 空标注就是「主人本人」,因此这条路径上的无昵称非主人若写成空串,就会被渲染成 +// 「来自主人」——正是本切片修掉的那个误认的镜像(webhook 适配器的 senderName +// 是可选参数且无兜底,可达)。 +// +// 用例走真链路:静默登记 → 落库 → 下一回合播种转录 → loop 归属渲染 → provider 请求。 +func TestSilencedNamelessMemberIsNotReadAsTheOwner(t *testing.T) { + t.Parallel() + + ad := &captureModelAdapter{} + // live 夹具让 Recent 连本次运行已落库的行一起返回 —— 真库就是这样。 + hist := &seededHistory{captureHistory: &captureHistory{}, live: true} + sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} + b, err := New(Options{Loop: groupLoopFor(t, ad), Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist}) + if err != nil { + t.Fatalf("New: %v", err) + } + + // ① 一条既没 @ 她、也没有规则信号、且**没有昵称**的群员消息:只登记、不回。 + b.process(context.Background(), noMentionGroupMessage("8", "", "我是群里一个没昵称的人")) + if ad.count() != 0 { + t.Fatalf("静默消息驱动了循环(应当只登记)") + } + recs := hist.records() + if len(recs) != 1 { + t.Fatalf("静默登记行数 = %d, want 1", len(recs)) + } + // 写入口径:非主人恒有标注 —— 空 sender_name 在模型侧就是「主人本人」。 + // 这里用 Errorf(不中断):空串既会让上面这条红,也会让下面的模型侧 + // 断言红,两条一起报出来才看得见「空串 → 被读成主人」的完整因果。 + // 这条断言在修之前会红(senderDisplayName 无昵称时返回空串)。 + if recs[0].SenderName != "8" { + t.Errorf("静默登记的 sender_name = %q, want 8(空串会被读成主人)", recs[0].SenderName) + } + + // ② 之后一条 @ 她的群消息驱动回合,静默那条随转录读回上下文。 + b.process(context.Background(), namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember})) + req := ad.lastRequest(t) + texts := requestTextsOf(req) + // 主断言(本用例守的就是它):静默行绝不能被渲染成主人本人的发言。 + for _, txt := range texts { + if strings.Contains(txt, ownerNotePrefix) { + t.Fatalf("无昵称的非主人被读成了主人:\n%s", requestDump(req)) + } + } + if !containsText(texts, "[发言归属] 以下发言来自「8」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。") { + t.Fatalf("静默的无昵称群员没有第三方标注:\n%s", requestDump(req)) + } + if !containsText(texts, "我是群里一个没昵称的人") { + t.Fatalf("静默那条没有出现在她的上下文里:\n%s", requestDump(req)) + } + t.Logf("模型侧实际形态:\n%s", requestDump(req)) +} + // requestTextsOf 按顺序返回请求里每条消息的正文。 func requestTextsOf(req model.Request) []string { out := make([]string, 0, len(req.Messages)) diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 575da37..8c91a3e 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -261,7 +261,13 @@ func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessag // 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。 // turn_id 同样传 "":这条消息**根本不会被回答**,不存在「它属于哪一路回合」, // 给它编一个标识就是把不属于任何回合的行说成有回合(见 turnid.go 的口径)。 - b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a), "") + // + // 发送者标注与回帖路径**同源**(thirdPartySenderName):非主人恒有标注 + // (昵称 → actor uid → 「未署名发送者」),主人恒空。静默行会被读回她的 + // 上下文(transcript.go),而受限会话里空标注就是「主人本人」——用 + // senderDisplayName 会在无昵称时写出空串,把一条陌生人消息渲染成 + // 「来自主人(管理员账户)本人」(review p1:本次修掉的 bug 的镜像)。 + b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, thirdPartySenderName(msg, actor), adapterName(a), "") b.log.Debug("non-owner message silenced, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID) }