契约(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 红。
208 lines
7.6 KiB
Go
208 lines
7.6 KiB
Go
// 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)
|
||
}
|