diff --git a/internal/channel/adapter/message.go b/internal/channel/adapter/message.go index b1bdf47..3365f2e 100644 --- a/internal/channel/adapter/message.go +++ b/internal/channel/adapter/message.go @@ -116,17 +116,30 @@ type UnifiedMessage struct { SenderID string SenderName string Sender Actor + // SelfID 是**她自己在该平台的账号 id**(OneBot 的 self_id、KOOK 的 bot_id…), + // 只从平台元数据取,绝不从正文推断。空串 = 平台没给 / 适配器尚未接线:此时 + // 「是否指向她」的判定必须 fail-closed(见 AddressedToMe),判据缺失不等于命中。 + SelfID string Content string ContentType ContentType Media []Media Mentions []Mention - ReplyToID string - ReplyToText string - GroupID string - GroupName string - ThreadID string - MessageID string - Timestamp time.Time + // MentionAll 报告本条是否 **@全体**(OneBot 的 at 段 qq="all";design/26 §2.4 要求 + // 以结构化标志留痕的 mention_all)。它是 AddressedToMe 的子集信号:true ⇒ 该字段也 true。 + // 单独留一列是为了让「@全体」与「直接点名她」在数据上可分辨(审计 / 降噪各取所需)。 + MentionAll bool + // AddressedToMe 报告本条消息在结构上**是否指向她本人**(design/18 §2 触发① 的判据, + // 主人 2026-10-13 裁决「甲 = 要」)。语义与推导见 AddressedToSelf: + // 群聊 = 点名她或 @全体;私聊 = 恒 true;平台未给 SelfID = false(fail-closed)。 + // 判据**全部来自平台元数据**,绝不匹配正文里的 "@<数字>"(项目红线)。 + AddressedToMe bool + ReplyToID string + ReplyToText string + GroupID string + GroupName string + ThreadID string + MessageID string + Timestamp time.Time // RawData is the opaque native payload the message was converted from. RawData []byte } @@ -159,3 +172,36 @@ func (m UnifiedMessage) Validate() error { // IsGroup reports whether the message belongs to a group conversation. func (m UnifiedMessage) IsGroup() bool { return m.ChannelType == ChannelGroup } + +// MentionedSelf 报告 mentions 里是否**点名**了 selfID:结构化 id 的精确比对, +// 绝不做正文匹配。selfID 为空(平台没给她的账号 id,或适配器未接线)或 mentions +// 为空时一律 false —— 判据缺失就 fail-closed,绝不猜。它是各适配器接线的唯一出处: +// 适配器只需把平台给出的 SelfID 与 Mentions 交给它。 +func MentionedSelf(selfID string, mentions []Mention) bool { + if selfID == "" { + return false + } + for _, m := range mentions { + if m.UserID == selfID { + return true + } + } + return false +} + +// AddressedToSelf 依据本条消息的**结构化平台元数据**推导 AddressedToMe +// (design/18 §2 触发①;主人 2026-10-13 裁决「甲 = 要」): +// +// - 私聊:恒 true —— 对端就是发送者,本条天然是对她说的; +// - 群聊:点名了她(Mentions 含 SelfID)**或** @全体(MentionAll); +// - SelfID 为空:一律 false —— 判据缺失 ⇒ fail-closed,绝不退回正文文本匹配。 +// +// 适配器在填好 SelfID / ChannelType / Mentions / MentionAll 之后调用它回写 +// AddressedToMe;推导的**唯一出处**在这里,避免每个适配器各写一份判据而互相漂移。 +// 它只用于**真消息**(走 toUnified 的那条路径),系统事件包装不适用。 +func (m UnifiedMessage) AddressedToSelf() bool { + if m.ChannelType == ChannelDirect { + return true + } + return m.MentionAll || MentionedSelf(m.SelfID, m.Mentions) +} diff --git a/internal/channel/adapter/message_test.go b/internal/channel/adapter/message_test.go index eaf4adb..0ed63a0 100644 --- a/internal/channel/adapter/message_test.go +++ b/internal/channel/adapter/message_test.go @@ -149,3 +149,105 @@ func TestContentTypeValid(t *testing.T) { t.Error("bogus ContentType.Valid() = true, want false") } } + +func TestMentionedSelf(t *testing.T) { + t.Parallel() + + mentions := []Mention{{UserID: "all"}, {UserID: "u-2"}} + + tests := []struct { + name string + selfID string + mentions []Mention + want bool + }{ + {name: "platform has not provided self id", selfID: "", mentions: mentions, want: false}, + {name: "no mentions", selfID: "u-2", mentions: nil, want: false}, + {name: "points at someone else", selfID: "u-9", mentions: mentions, want: false}, + {name: "points at her", selfID: "u-2", mentions: mentions, want: true}, + {name: "at-all marker is not a user id", selfID: "all", mentions: nil, want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := MentionedSelf(tt.selfID, tt.mentions); got != tt.want { + t.Errorf("MentionedSelf(%q, %+v) = %v, want %v", tt.selfID, tt.mentions, got, tt.want) + } + }) + } +} + +func TestAddressedToSelf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg UnifiedMessage + want bool + }{ + { + name: "private chat is always addressed to her", + msg: UnifiedMessage{ChannelType: ChannelDirect, SelfID: "u-1"}, + want: true, + }, + { + name: "private chat without platform self id stays addressed", + msg: UnifiedMessage{ChannelType: ChannelDirect}, + want: true, + }, + { + name: "group mentions her", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", + Mentions: []Mention{{UserID: "u-1"}}, + }, + want: true, + }, + { + name: "group mentions someone else", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", + Mentions: []Mention{{UserID: "u-2"}}, + }, + want: false, + }, + { + name: "group mentions all", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", MentionAll: true, + Mentions: []Mention{{UserID: "all"}}, + }, + want: true, + }, + { + name: "group without mention", + msg: UnifiedMessage{ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1"}, + want: false, + }, + { + name: "group without platform self id is fail-closed", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, GroupID: "g-1", + Mentions: []Mention{{UserID: "u-1"}}, + }, + want: false, + }, + { + name: "zero value stays fail-closed for adapters that do not wire it", + msg: UnifiedMessage{}, + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.msg.AddressedToSelf(); got != tt.want { + t.Errorf("AddressedToSelf() = %v, want %v (msg=%+v)", got, tt.want, tt.msg) + } + }) + } +} diff --git a/plugins/channels/qq_obv11/addressed.go b/plugins/channels/qq_obv11/addressed.go new file mode 100644 index 0000000..68aa8ce --- /dev/null +++ b/plugins/channels/qq_obv11/addressed.go @@ -0,0 +1,46 @@ +package qq_obv11 + +// 本文件承载「这条消息是不是在叫她」的**结构化接线**:把 OneBot 事件里已有的 +// self_id(她自己在该平台的账号 id)与 at 段(点名 / @全体)写进统一契约 +// adapter.UnifiedMessage,让下游不必再靠「Mentions 非空」这种 **@ 谁不论**的判据 +// (design/18 §2 触发① 的 2026-10-13 修订;主人裁决「甲 = 要」)。 +// +// 红线:判据**全部来自平台元数据**(self_id + at 段的 qq),绝不匹配正文里的 +// "@<数字>" —— 正文里恰好只有这种文本,而它正是不可靠的原因(@ 她与 @ 别人长得一模一样)。 +// +// 只覆盖**真消息**那条路径(toUnified)。debug 帧日志(inbound_debug.go)与 notice 的 +// self 命中(notice.go / event.go)各有各的语义,本片不动它们。 + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// mentionAllQQ 是 OneBot v11 at 段里 **@全体成员** 的 qq 取值 +// (数组段 `{"type":"at","data":{"qq":"all"}}`,CQ 码形态 `[CQ:at,qq=all]`)。 +// design/26 §2.4 把它登记为随消息到达的**结构化标志**(mention_all),不另立一类事件。 +// 该字面量是 OneBot 协议约定,所以落在平台侧(这里),不污染平台中立的 adapter 包。 +const mentionAllQQ = "all" + +// mentionsContainAll 报告 mentions 里是否含 @全体。只比对结构化的 UserID 字段, +// 不看正文;判据是精确相等(QQ 号是纯数字,与 "all" 不可能撞号)。 +func mentionsContainAll(mentions []adapter.Mention) bool { + for _, m := range mentions { + if m.UserID == mentionAllQQ { + return true + } + } + return false +} + +// applyAddressing 把平台元数据写成统一契约里的结构化字段:SelfID(她自己在该平台的 +// 账号 id)、MentionAll(是否 @全体)、AddressedToMe(本条是否指向她本人,推导见 +// adapter.UnifiedMessage.AddressedToSelf)。三个字段同源同步,下游读哪个都不会自相矛盾。 +// +// 调用点必须在 ChannelType 判定**之后**:私聊与群聊的 AddressedToMe 取值不同。 +// self_id 取不到(字段缺失 / 为 0)时 SelfID 如实留空、AddressedToMe 随之 false —— +// fail-closed,绝不退回正文匹配(见 adapter.MentionedSelf)。 +func applyAddressing(msg *adapter.UnifiedMessage, selfID string, mentions []adapter.Mention) { + msg.SelfID = selfID + msg.MentionAll = mentionsContainAll(mentions) + msg.AddressedToMe = msg.AddressedToSelf() +} diff --git a/plugins/channels/qq_obv11/addressed_test.go b/plugins/channels/qq_obv11/addressed_test.go new file mode 100644 index 0000000..090c940 --- /dev/null +++ b/plugins/channels/qq_obv11/addressed_test.go @@ -0,0 +1,176 @@ +package qq_obv11 + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// TestToUnifiedAddressedToMe 是 M11 的核心验收:**五种情形**(私聊 / @她 / @别人 / +// @全体 / 无 @)各断言结构化字段 SelfID + MentionAll + AddressedToMe。判据必须 +// 全部来自平台元数据(self_id + at 段的 qq),绝不做正文匹配。 +// +// 实测背景(DevDocs/audit/QQ群链路诊断-2026-10-13.md):旧判据是「Mentions 非空」, +// @ 谁不论 ⇒ 群 672432767 里群员 @2116733327(@ 别人)两次都被回。下面的 +// group_mention_other 就是那条反例的回归用例。 +func TestToUnifiedAddressedToMe(t *testing.T) { + t.Parallel() + + const selfID = "2116733327" + + tests := []struct { + name string + raw string + wantSelfID string + wantMentionAll bool + wantAddressed bool + wantMentions []string + wantReplyTo string + }{ + { + // 私聊:对端就是发送者,本条天然是对她说的 ⇒ true(与有没有 @ 无关)。 + name: "private_no_mention", + raw: `{"post_type":"message","message_type":"private","time":1700000000,"self_id":2116733327,"message_id":1,"user_id":10001,"message":[{"type":"text","data":{"text":"在吗"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: true, + }, + { + name: "group_mention_me", + raw: `{"post_type":"message","message_type":"group","time":1700000001,"self_id":2116733327,"message_id":2,"user_id":10001,"group_id":672432767,"message":[{"type":"at","data":{"qq":"2116733327"}},{"type":"text","data":{"text":" 帮我看看"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: true, + wantMentions: []string{"2116733327"}, + }, + { + // 反例回归:@ 的是别人 ⇒ 不得判成「在叫她」。 + name: "group_mention_other", + raw: `{"post_type":"message","message_type":"group","time":1700000002,"self_id":2116733327,"message_id":3,"user_id":10001,"group_id":672432767,"message":[{"type":"at","data":{"qq":"999888777"}},{"type":"text","data":{"text":" 你看这个"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: false, + wantMentions: []string{"999888777"}, + }, + { + // @全体:design/18 §2 触发① 与 design/26 §2.4 都把它算作指向她, + // 用独立的结构化标志 MentionAll 记录,AddressedToMe 同步为 true。 + name: "group_mention_all", + raw: `{"post_type":"message","message_type":"group","time":1700000003,"self_id":2116733327,"message_id":4,"user_id":10001,"group_id":672432767,"message":[{"type":"at","data":{"qq":"all"}},{"type":"text","data":{"text":" 全体注意"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: true, + wantAddressed: true, + wantMentions: []string{"all"}, + }, + { + name: "group_no_mention", + raw: `{"post_type":"message","message_type":"group","time":1700000004,"self_id":2116733327,"message_id":5,"user_id":10001,"group_id":672432767,"message":[{"type":"text","data":{"text":"今天天气不错"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: false, + }, + { + // CQ 码形态(老实现)同样接线:@全体在 CQ 里是 qq=all。 + name: "cq_group_mention_all", + raw: `{"post_type":"message","message_type":"group","time":1700000005,"self_id":2116733327,"message_id":6,"user_id":10001,"group_id":672432767,"message":"[CQ:at,qq=all]全体注意","sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: true, + wantAddressed: true, + wantMentions: []string{"all"}, + }, + { + // CQ 码形态 @别人 ⇒ 同样不得判成在叫她。 + name: "cq_group_mention_other", + raw: `{"post_type":"message","message_type":"group","time":1700000006,"self_id":2116733327,"message_id":7,"user_id":10001,"group_id":672432767,"message":"[CQ:at,qq=999888777]你看","sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: false, + wantMentions: []string{"999888777"}, + }, + { + // 同时 @ 她与别人 ⇒ 仍是「在叫她」。 + name: "group_mention_me_and_other", + raw: `{"post_type":"message","message_type":"group","time":1700000007,"self_id":2116733327,"message_id":8,"user_id":10001,"group_id":672432767,"message":[{"type":"at","data":{"qq":"999888777"}},{"type":"at","data":{"qq":"2116733327"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: true, + wantMentions: []string{"999888777", "2116733327"}, + }, + { + // fail-closed:平台没给 self_id(=0)时判据缺失,即使有 @ 也不得判成在叫她; + // SelfID 如实留空,绝不编一个 id。 + name: "group_self_id_absent_fail_closed", + raw: `{"post_type":"message","message_type":"group","time":1700000008,"message_id":9,"user_id":10001,"group_id":672432767,"message":[{"type":"at","data":{"qq":"2116733327"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: "", + wantMentionAll: false, + wantAddressed: false, + wantMentions: []string{"2116733327"}, + }, + { + // 「接她的话」的现状:回复段只给出 **被回复消息的 id**,它本身不携带「那条是不是 + // 她自己发的」。本轮不据此判定 AddressedToMe(见 M11 报告的可行性结论)。 + name: "group_reply_without_mention", + raw: `{"post_type":"message","message_type":"group","time":1700000009,"self_id":2116733327,"message_id":10,"user_id":10001,"group_id":672432767,"message":[{"type":"reply","data":{"id":"555","text":"她上一条"}},{"type":"text","data":{"text":"那再问一句"}}],"sender":{"user_id":10001,"nickname":"甲","role":"member"}}`, + wantSelfID: selfID, + wantMentionAll: false, + wantAddressed: false, + wantReplyTo: "555", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ev, err := parseEvent([]byte(tt.raw)) + if err != nil { + t.Fatalf("parseEvent error: %v", err) + } + msg := NewAdapter(Config{}).toUnified(ev, []byte(tt.raw)) + + if msg.SelfID != tt.wantSelfID { + t.Errorf("SelfID = %q, want %q", msg.SelfID, tt.wantSelfID) + } + if msg.MentionAll != tt.wantMentionAll { + t.Errorf("MentionAll = %v, want %v", msg.MentionAll, tt.wantMentionAll) + } + if msg.AddressedToMe != tt.wantAddressed { + t.Errorf("AddressedToMe = %v, want %v (Mentions=%+v SelfID=%q)", + msg.AddressedToMe, tt.wantAddressed, msg.Mentions, msg.SelfID) + } + // 既有字段语义与形状保持不变。 + if len(msg.Mentions) != len(tt.wantMentions) { + t.Fatalf("Mentions = %+v, want %v", msg.Mentions, tt.wantMentions) + } + for i, want := range tt.wantMentions { + if msg.Mentions[i].UserID != want { + t.Errorf("Mentions[%d].UserID = %q, want %q", i, msg.Mentions[i].UserID, want) + } + } + if msg.ReplyToID != tt.wantReplyTo { + t.Errorf("ReplyToID = %q, want %q", msg.ReplyToID, tt.wantReplyTo) + } + if tt.wantReplyTo != "" && msg.ReplyToText != "她上一条" { + t.Errorf("ReplyToText = %q, want 她上一条", msg.ReplyToText) + } + }) + } +} + +// TestApplyAddressingUnwiredSelfID 钉住「判据缺失 ⇒ fail-closed」这条:SelfID 为空时 +// 群聊一律 false,绝不因为 Mentions 非空就放行(那正是旧判据的错法)。 +func TestApplyAddressingUnwiredSelfID(t *testing.T) { + t.Parallel() + + msg := adapter.UnifiedMessage{ + ChannelType: adapter.ChannelGroup, + Mentions: []adapter.Mention{{UserID: "999888777"}}, + } + applyAddressing(&msg, "", msg.Mentions) + + if msg.SelfID != "" || msg.MentionAll || msg.AddressedToMe { + t.Errorf("unwired self id: SelfID=%q MentionAll=%v AddressedToMe=%v, want all zero", + msg.SelfID, msg.MentionAll, msg.AddressedToMe) + } +} diff --git a/plugins/channels/qq_obv11/message.go b/plugins/channels/qq_obv11/message.go index 10bbdff..41ad749 100644 --- a/plugins/channels/qq_obv11/message.go +++ b/plugins/channels/qq_obv11/message.go @@ -117,6 +117,9 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage { text, media, mentions, replyID, replyText, hasText := a.parseMessage(ev) senderID := senderIDOf(ev) + // selfID 是**她自己**在该平台的账号 id(OneBot 的 self_id),只用于 + // 「本条是否指向她」的结构化判定(见 applyAddressing),绝不参与判主。 + selfID := formatID(ev.SelfID) senderName := "unknown" role := adapter.RoleUnknown if ev.Sender != nil { @@ -169,6 +172,8 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage { // 充当 SenderID 会让**任何群成员**都顶着群号去撞白名单——见 group 分支。 msg.ChannelID = senderID } + // 结构化「是否在叫她」接线必须在 ChannelType 判定之后(私聊恒 true)。 + applyAddressing(&msg, selfID, mentions) return msg }