fix(channel): 静默登记同源第三方标注 —— 无昵称非主人不再被读成主人

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 ./...(全树)全绿。
This commit is contained in:
AskaEth committed 2026-10-13 20:34:04 +08:00
1 parent 8ab241f627
commit 91e4dd2fe1
2 files changed
+75 -1

No files matched your search

@@ -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))
+7 -1
View File
@@ -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)
}