| 1 | // Package bot 实现 Reasonix 多渠道 IM bot 消息网关,支持 QQ、飞书、微信。 |
| 2 | // 架构参考 Hermes Agent 的 gateway/adapter/session 模式。 |
| 3 | package bot |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // Platform 标识 IM 平台。 |
| 11 | type Platform string |
| 12 | |
| 13 | const ( |
| 14 | PlatformQQ Platform = "qq" |
| 15 | PlatformFeishu Platform = "feishu" |
| 16 | PlatformWeixin Platform = "weixin" |
| 17 | ) |
| 18 | |
| 19 | // ChatType 标识会话类型。 |
| 20 | type ChatType string |
| 21 | |
| 22 | const ( |
| 23 | ChatDM ChatType = "dm" |
| 24 | ChatGroup ChatType = "group" |
| 25 | ChatGuild ChatType = "guild" |
| 26 | ChatDirect ChatType = "direct" |
| 27 | ChatThread ChatType = "thread" |
| 28 | ) |
| 29 | |
| 30 | // SessionSource 是会话的复合标识,用于生成稳定的 session key。 |
| 31 | type SessionSource struct { |
| 32 | Platform Platform `json:"platform"` |
| 33 | ConnectionID string `json:"connection_id,omitempty"` |
| 34 | Domain string `json:"domain,omitempty"` |
| 35 | ChatType ChatType `json:"chat_type"` |
| 36 | ChatID string `json:"chat_id"` |
| 37 | UserID string `json:"user_id"` |
| 38 | ThreadID string `json:"thread_id,omitempty"` |
| 39 | } |
| 40 | |
| 41 | // InboundMedia is an authenticated inbound attachment. Adapters may provide |
| 42 | // Data directly, or a lazy Load callback for platform resources that must not |
| 43 | // be fetched until the gateway has admitted the sender through its allowlist. |
| 44 | type InboundMedia struct { |
| 45 | Name string `json:"name,omitempty"` |
| 46 | MIME string `json:"mime,omitempty"` |
| 47 | Data []byte `json:"-"` |
| 48 | Load func(context.Context) ([]byte, string, error) `json:"-"` |
| 49 | FailureText string `json:"-"` |
| 50 | } |
| 51 | |
| 52 | // InboundMessage 是从任一平台收到的入站消息。 |
| 53 | type InboundMessage struct { |
| 54 | Platform Platform `json:"platform"` |
| 55 | ConnectionID string `json:"connection_id,omitempty"` |
| 56 | Domain string `json:"domain,omitempty"` |
| 57 | ChatType ChatType `json:"chat_type"` |
| 58 | ChatID string `json:"chat_id"` |
| 59 | UserID string `json:"user_id"` |
| 60 | UserName string `json:"user_name"` |
| 61 | // OperatorID, when set, is the authenticated actor gated by the allowlist; UserID stays routing-only. |
| 62 | OperatorID string `json:"operator_id,omitempty"` |
| 63 | Text string `json:"text"` |
| 64 | MessageID string `json:"message_id"` |
| 65 | ThreadID string `json:"thread_id,omitempty"` |
| 66 | MediaURLs []string `json:"media_urls,omitempty"` |
| 67 | Media []InboundMedia `json:"-"` |
| 68 | // ResolveUserName performs optional platform enrichment after admission. |
| 69 | // UserName remains the safe fallback when the callback is nil or fails. |
| 70 | ResolveUserName func(context.Context) string `json:"-"` |
| 71 | Raw any `json:"-"` |
| 72 | } |
| 73 | |
| 74 | // Session derives the SessionSource from this message. |
| 75 | func (m InboundMessage) Session() SessionSource { |
| 76 | return SessionSource{ |
| 77 | Platform: m.Platform, |
| 78 | ConnectionID: m.ConnectionID, |
| 79 | Domain: m.Domain, |
| 80 | ChatType: m.ChatType, |
| 81 | ChatID: m.ChatID, |
| 82 | UserID: m.UserID, |
| 83 | ThreadID: m.ThreadID, |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // OutboundMessage 是发送到平台的消息。 |
| 88 | type OutboundMessage struct { |
| 89 | ConnectionID string `json:"connection_id,omitempty"` |
| 90 | Domain string `json:"domain,omitempty"` |
| 91 | ChatID string `json:"chat_id"` |
| 92 | ChatType ChatType `json:"chat_type,omitempty"` |
| 93 | Text string `json:"text,omitempty"` |
| 94 | MediaURLs []string `json:"media_urls,omitempty"` |
| 95 | ReplyToMsgID string `json:"reply_to_msg_id,omitempty"` |
| 96 | Keyboard *InlineKeyboard `json:"keyboard,omitempty"` |
| 97 | Card *InteractiveCard `json:"card,omitempty"` |
| 98 | } |
| 99 | |
| 100 | // InlineKeyboard 是内联键盘(用于 QQ 审批)。 |
| 101 | type InlineKeyboard struct { |
| 102 | Rows []InlineKeyboardRow `json:"rows"` |
| 103 | } |
| 104 | |
| 105 | // InlineKeyboardRow 是一行按钮。 |
| 106 | type InlineKeyboardRow struct { |
| 107 | Buttons []InlineKeyboardButton `json:"buttons"` |
| 108 | } |
| 109 | |
| 110 | // InlineKeyboardButton 是一个按钮。 |
| 111 | type InlineKeyboardButton struct { |
| 112 | ID string `json:"id"` |
| 113 | Label string `json:"label"` |
| 114 | Style int `json:"style,omitempty"` // 0=default, 1=primary, 2=danger |
| 115 | CallbackID string `json:"callback_id,omitempty"` |
| 116 | } |
| 117 | |
| 118 | // InteractiveCard 是交互式卡片(用于飞书审批/问答)。 |
| 119 | type InteractiveCard struct { |
| 120 | Header string `json:"header"` |
| 121 | Elements []InteractiveCardElement `json:"elements"` |
| 122 | } |
| 123 | |
| 124 | // InteractiveCardElement 是卡片内元素。 |
| 125 | type InteractiveCardElement struct { |
| 126 | Tag string `json:"tag"` |
| 127 | Content string `json:"content,omitempty"` |
| 128 | Extra map[string]any `json:"extra,omitempty"` |
| 129 | } |
| 130 | |
| 131 | // SendResult 是发送消息的结果。 |
| 132 | type SendResult struct { |
| 133 | MessageID string `json:"message_id,omitempty"` |
| 134 | MessageIDs []string `json:"message_ids,omitempty"` |
| 135 | Err error `json:"err,omitempty"` |
| 136 | } |
| 137 | |
| 138 | // DeliveredMessageIDs returns every known delivered message ID, including the |
| 139 | // legacy singular MessageID field, in delivery order without duplicates. |
| 140 | func (r SendResult) DeliveredMessageIDs() []string { |
| 141 | ids := make([]string, 0, len(r.MessageIDs)+1) |
| 142 | add := func(id string) { |
| 143 | id = strings.TrimSpace(id) |
| 144 | if id == "" { |
| 145 | return |
| 146 | } |
| 147 | for _, existing := range ids { |
| 148 | if existing == id { |
| 149 | return |
| 150 | } |
| 151 | } |
| 152 | ids = append(ids, id) |
| 153 | } |
| 154 | for _, id := range r.MessageIDs { |
| 155 | add(id) |
| 156 | } |
| 157 | add(r.MessageID) |
| 158 | return ids |
| 159 | } |
| 160 | |
| 161 | // Merge appends delivered IDs from another send while keeping MessageID as the |
| 162 | // last delivered ID for callers using the legacy singular field. |
| 163 | func (r *SendResult) Merge(delivered SendResult) { |
| 164 | for _, id := range delivered.DeliveredMessageIDs() { |
| 165 | duplicate := false |
| 166 | for _, existing := range r.MessageIDs { |
| 167 | if existing == id { |
| 168 | duplicate = true |
| 169 | break |
| 170 | } |
| 171 | } |
| 172 | if !duplicate { |
| 173 | r.MessageIDs = append(r.MessageIDs, id) |
| 174 | } |
| 175 | r.MessageID = id |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // Adapter 是平台适配器接口,每个平台实现一个。 |
| 180 | type Adapter interface { |
| 181 | // Platform 返回平台标识。 |
| 182 | Platform() Platform |
| 183 | |
| 184 | // Start 启动适配器,连接平台 gateway。 |
| 185 | Start(ctx context.Context) error |
| 186 | |
| 187 | // Stop 优雅关闭适配器。 |
| 188 | Stop() error |
| 189 | |
| 190 | // Send 发送一条出站消息。 |
| 191 | Send(ctx context.Context, msg OutboundMessage) (SendResult, error) |
| 192 | |
| 193 | // SendTyping 发送"正在输入"状态。 |
| 194 | SendTyping(ctx context.Context, chatID string) error |
| 195 | |
| 196 | // Messages 返回入站消息通道。 |
| 197 | Messages() <-chan InboundMessage |
| 198 | |
| 199 | // Name 返回适配器实例名(用于日志)。 |
| 200 | Name() string |
| 201 | } |
| 202 | |
| 203 | // MessageHandler 是 BotGateway 处理入站消息的回调。 |
| 204 | type MessageHandler func(ctx context.Context, msg InboundMessage) |
| 205 |