205 lines
6.5 KiB
Go
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)
|