Files
2026-07-13 13:00:08 +08:00

205 lines
6.5 KiB
Go

// Package bot 实现 Reasonix 多渠道 IM bot 消息网关,支持 QQ、飞书、微信。
// 架构参考 Hermes Agent 的 gateway/adapter/session 模式。
package bot
import (
"context"
"strings"
)
// Platform 标识 IM 平台。
type Platform string
const (
PlatformQQ Platform = "qq"
PlatformFeishu Platform = "feishu"
PlatformWeixin Platform = "weixin"
)
// ChatType 标识会话类型。
type ChatType string
const (
ChatDM ChatType = "dm"
ChatGroup ChatType = "group"
ChatGuild ChatType = "guild"
ChatDirect ChatType = "direct"
ChatThread ChatType = "thread"
)
// SessionSource 是会话的复合标识,用于生成稳定的 session key。
type SessionSource struct {
Platform Platform `json:"platform"`
ConnectionID string `json:"connection_id,omitempty"`
Domain string `json:"domain,omitempty"`
ChatType ChatType `json:"chat_type"`
ChatID string `json:"chat_id"`
UserID string `json:"user_id"`
ThreadID string `json:"thread_id,omitempty"`
}
// InboundMedia is an authenticated inbound attachment. Adapters may provide
// Data directly, or a lazy Load callback for platform resources that must not
// be fetched until the gateway has admitted the sender through its allowlist.
type InboundMedia struct {
Name string `json:"name,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"-"`
Load func(context.Context) ([]byte, string, error) `json:"-"`
FailureText string `json:"-"`
}
// InboundMessage 是从任一平台收到的入站消息。
type InboundMessage struct {
Platform Platform `json:"platform"`
ConnectionID string `json:"connection_id,omitempty"`
Domain string `json:"domain,omitempty"`
ChatType ChatType `json:"chat_type"`
ChatID string `json:"chat_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
// OperatorID, when set, is the authenticated actor gated by the allowlist; UserID stays routing-only.
OperatorID string `json:"operator_id,omitempty"`
Text string `json:"text"`
MessageID string `json:"message_id"`
ThreadID string `json:"thread_id,omitempty"`
MediaURLs []string `json:"media_urls,omitempty"`
Media []InboundMedia `json:"-"`
// ResolveUserName performs optional platform enrichment after admission.
// UserName remains the safe fallback when the callback is nil or fails.
ResolveUserName func(context.Context) string `json:"-"`
Raw any `json:"-"`
}
// Session derives the SessionSource from this message.
func (m InboundMessage) Session() SessionSource {
return SessionSource{
Platform: m.Platform,
ConnectionID: m.ConnectionID,
Domain: m.Domain,
ChatType: m.ChatType,
ChatID: m.ChatID,
UserID: m.UserID,
ThreadID: m.ThreadID,
}
}
// OutboundMessage 是发送到平台的消息。
type OutboundMessage struct {
ConnectionID string `json:"connection_id,omitempty"`
Domain string `json:"domain,omitempty"`
ChatID string `json:"chat_id"`
ChatType ChatType `json:"chat_type,omitempty"`
Text string `json:"text,omitempty"`
MediaURLs []string `json:"media_urls,omitempty"`
ReplyToMsgID string `json:"reply_to_msg_id,omitempty"`
Keyboard *InlineKeyboard `json:"keyboard,omitempty"`
Card *InteractiveCard `json:"card,omitempty"`
}
// InlineKeyboard 是内联键盘(用于 QQ 审批)。
type InlineKeyboard struct {
Rows []InlineKeyboardRow `json:"rows"`
}
// InlineKeyboardRow 是一行按钮。
type InlineKeyboardRow struct {
Buttons []InlineKeyboardButton `json:"buttons"`
}
// InlineKeyboardButton 是一个按钮。
type InlineKeyboardButton struct {
ID string `json:"id"`
Label string `json:"label"`
Style int `json:"style,omitempty"` // 0=default, 1=primary, 2=danger
CallbackID string `json:"callback_id,omitempty"`
}
// InteractiveCard 是交互式卡片(用于飞书审批/问答)。
type InteractiveCard struct {
Header string `json:"header"`
Elements []InteractiveCardElement `json:"elements"`
}
// InteractiveCardElement 是卡片内元素。
type InteractiveCardElement struct {
Tag string `json:"tag"`
Content string `json:"content,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// SendResult 是发送消息的结果。
type SendResult struct {
MessageID string `json:"message_id,omitempty"`
MessageIDs []string `json:"message_ids,omitempty"`
Err error `json:"err,omitempty"`
}
// DeliveredMessageIDs returns every known delivered message ID, including the
// legacy singular MessageID field, in delivery order without duplicates.
func (r SendResult) DeliveredMessageIDs() []string {
ids := make([]string, 0, len(r.MessageIDs)+1)
add := func(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
for _, existing := range ids {
if existing == id {
return
}
}
ids = append(ids, id)
}
for _, id := range r.MessageIDs {
add(id)
}
add(r.MessageID)
return ids
}
// Merge appends delivered IDs from another send while keeping MessageID as the
// last delivered ID for callers using the legacy singular field.
func (r *SendResult) Merge(delivered SendResult) {
for _, id := range delivered.DeliveredMessageIDs() {
duplicate := false
for _, existing := range r.MessageIDs {
if existing == id {
duplicate = true
break
}
}
if !duplicate {
r.MessageIDs = append(r.MessageIDs, id)
}
r.MessageID = id
}
}
// Adapter 是平台适配器接口,每个平台实现一个。
type Adapter interface {
// Platform 返回平台标识。
Platform() Platform
// Start 启动适配器,连接平台 gateway。
Start(ctx context.Context) error
// Stop 优雅关闭适配器。
Stop() error
// Send 发送一条出站消息。
Send(ctx context.Context, msg OutboundMessage) (SendResult, error)
// SendTyping 发送"正在输入"状态。
SendTyping(ctx context.Context, chatID string) error
// Messages 返回入站消息通道。
Messages() <-chan InboundMessage
// Name 返回适配器实例名(用于日志)。
Name() string
}
// MessageHandler 是 BotGateway 处理入站消息的回调。
type MessageHandler func(ctx context.Context, msg InboundMessage)