Files
AskaEth b26bc55a15 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 红。
2026-10-13 20:17:56 +08:00

208 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package adapter defines the cross-platform IM channel adaptation contract for
// Cyrene v2. It declares the normalized message model, the per-platform adapter
// interface, the capability declaration, and the strongly typed identity (actor)
// metadata that the channel layer uses for owner detection and group-chat
// isolation.
//
// The contract is intentionally platform-neutral: concrete adapters (qq-obv11,
// weixin-ilink, telegram, webhook) are implemented behind this interface in
// milestone M6, not here. The design authority is DevDocs/design/08-渠道接入.md
// (sections 0-2) and DevDocs/design/02-会话与上下文模型.md (section 3).
package adapter
import (
"errors"
"fmt"
"time"
)
// ChannelType classifies where a message originated or is destined.
type ChannelType string
const (
// ChannelDirect is a one-to-one private conversation.
ChannelDirect ChannelType = "direct"
// ChannelGroup is a group / room conversation.
ChannelGroup ChannelType = "group"
)
// Valid reports whether ct is one of the known channel kinds.
func (ct ChannelType) Valid() bool {
switch ct {
case ChannelDirect, ChannelGroup:
return true
default:
return false
}
}
// ContentType classifies the primary payload of a message.
type ContentType string
const (
// ContentText is a plain-text chat message.
ContentText ContentType = "text"
// ContentImage is an image attachment.
ContentImage ContentType = "image"
// ContentVoice is a voice/audio clip.
ContentVoice ContentType = "voice"
// ContentFile is an arbitrary file attachment.
ContentFile ContentType = "file"
// ContentAction is a non-chat action signal (typing, join/leave, recall).
ContentAction ContentType = "action"
)
// Valid reports whether ct is one of the known content kinds.
func (ct ContentType) Valid() bool {
switch ct {
case ContentText, ContentImage, ContentVoice, ContentFile, ContentAction:
return true
default:
return false
}
}
// MediaKind classifies an individual media attachment. It is broader than
// ContentType so attachments can also carry video or other payloads.
type MediaKind string
const (
// MediaImage is an image file.
MediaImage MediaKind = "image"
// MediaVoice is an audio/voice file.
MediaVoice MediaKind = "voice"
// MediaVideo is a video file.
MediaVideo MediaKind = "video"
// MediaFile is an arbitrary file.
MediaFile MediaKind = "file"
)
// Media is a single attachment carried by a UnifiedMessage. URL and Data are
// mutually exclusive representations: prefer URL for remote media to
// keep payloads out of memory.
type Media struct {
// Kind classifies the attachment.
Kind MediaKind
// URL is the remote location of the media when not inlined.
URL string
// Name is the original file name, when known.
Name string
// Size is the media size in bytes.
Size int64
// MIMEType is the media content type, when known.
MIMEType string
// Data carries inlined bytes for small, locally produced payloads.
Data []byte
}
// Mention is a reference to a user inside a message body.
type Mention struct {
UserID string
UserName string
}
// UnifiedMessage is the normalized, platform-neutral representation of an IM
// event or inbound message. Every channel adapter converts its native payload
// into UnifiedMessage so the runtime can reason about them uniformly.
//
// SenderID/SenderName are kept per design/08 section 0; Sender carries the
// strongly typed identity metadata (Role, group flags) used for owner detection
// and group isolation. The strategy engine (M6) decides access from Sender, not
// from the free-text SenderName.
type UnifiedMessage struct {
Platform string
ChannelID string
ChannelType ChannelType
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
// 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
}
// UnifiedResponse is the normalized outbound payload for SendMessage. It may
// carry several messages so an adapter can burst a reply.
type UnifiedResponse struct {
// Messages are the reply messages to deliver.
Messages []UnifiedMessage
// ReplyTo is an optional context identifier the messages reply to.
ReplyTo string
// Hint is an optional human hint surfaced by the runtime.
Hint string
}
// Validate reports whether the message is well-formed for downstream handling.
// It checks the enum fields and requires a group id on group messages.
func (m UnifiedMessage) Validate() error {
if !m.ChannelType.Valid() {
return fmt.Errorf("adapter: invalid channel type %q", m.ChannelType)
}
if !m.ContentType.Valid() {
return fmt.Errorf("adapter: invalid content type %q", m.ContentType)
}
if m.ChannelType == ChannelGroup && m.GroupID == "" {
return errors.New("adapter: group message missing group id")
}
return nil
}
// 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)
}