feat(channel): 消息契约加「她自己账号 id / 是否在叫她」结构化判据并接线 qq_obv11
契约(internal/channel/adapter):UnifiedMessage 新增三个字段 —— SelfID(她自己在该 平台的账号 id)、MentionAll(是否 @全体,design/26 §2.4 的 mention_all)、AddressedToMe (本条是否指向她本人);判据的唯一出处是新增的 MentionedSelf(结构化 id 精确比对, 空 selfID 一律 false)与 AddressedToSelf(私聊恒 true;群聊 = 点名她或 @全体; 平台未给 SelfID ⇒ fail-closed)。全程不匹配正文文本。 接线(plugins/channels/qq_obv11):新增 addressed.go,把事件里早已解出、此前只用于 debug 日志与 notice 判定的 self_id 接进 toUnified,并从 at 段解出 @全体(qq=all), 在 ChannelType 判定之后统一回写三个字段。 design/18 §2 触发① 的旧口径「Mentions 非空」= @谁不论(实测群 672432767 里 @别人两次 被回);本片让消费侧(light.go:142,归 M12)能改用 AddressedToMe || MentionAll。 既有字段 Mentions/ReplyToID/ReplyToText 语义与形状不变。 测试:qq_obv11 十种帧形态(私聊/@她/@别人/@全体/无@/CQ 形态/fail-closed/纯回复) 逐条断言;adapter 侧覆盖 MentionedSelf 与 AddressedToSelf。变异自证:判据改回 「Mentions 非空」⇒ group_mention_other 红;摘掉 selfId 接线 ⇒ group_mention_me 红。
This commit is contained in:
1 parent
1cd38514f3
commit
b26bc55a15
5 files changed
+382
-7
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user