// 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) }