chore: update dependencies and improve bot configuration
- Upgrade Go version to 1.24.0 and update toolchain. - Update various dependencies in go.mod and go.sum, including: - Upgrade `fasthttp/websocket` to v1.5.12 - Upgrade `fsnotify/fsnotify` to v1.9.0 - Upgrade `valyala/fasthttp` to v1.58.0 - Add new dependencies for `bytedance/sonic` and `google/uuid`. - Refactor bot configuration in config.toml to support multiple bot protocols, including "milky" and "onebot11". - Modify internal configuration structures to accommodate new bot settings. - Enhance event dispatcher with metrics tracking and asynchronous processing capabilities. - Implement WebSocket connection management with heartbeat and reconnection logic. - Update server handling for bot management and event publishing.
This commit is contained in:
340
internal/adapter/milky/adapter.go
Normal file
340
internal/adapter/milky/adapter.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"cellbot/internal/engine"
|
||||
"cellbot/internal/protocol"
|
||||
"cellbot/pkg/net"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Config Milky 适配器配置
|
||||
type Config struct {
|
||||
// 协议端地址(如 http://localhost:3000)
|
||||
ProtocolURL string `toml:"protocol_url"`
|
||||
// 访问令牌
|
||||
AccessToken string `toml:"access_token"`
|
||||
// 事件接收方式: sse, websocket, webhook
|
||||
EventMode string `toml:"event_mode"`
|
||||
// Webhook 监听地址(仅当 event_mode = "webhook" 时需要)
|
||||
WebhookListenAddr string `toml:"webhook_listen_addr"`
|
||||
// 超时时间(秒)
|
||||
Timeout int `toml:"timeout"`
|
||||
// 重试次数
|
||||
RetryCount int `toml:"retry_count"`
|
||||
}
|
||||
|
||||
// Adapter Milky 协议适配器
|
||||
type Adapter struct {
|
||||
config *Config
|
||||
selfID string
|
||||
apiClient *APIClient
|
||||
sseClient *net.SSEClient
|
||||
wsManager *net.WebSocketManager
|
||||
wsConn *net.WebSocketConnection
|
||||
webhookServer *WebhookServer
|
||||
eventBus *engine.EventBus
|
||||
eventConverter *EventConverter
|
||||
logger *zap.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewAdapter 创建 Milky 适配器
|
||||
func NewAdapter(config *Config, selfID string, eventBus *engine.EventBus, wsManager *net.WebSocketManager, logger *zap.Logger) *Adapter {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
timeout := time.Duration(config.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
retryCount := config.RetryCount
|
||||
if retryCount == 0 {
|
||||
retryCount = 3
|
||||
}
|
||||
|
||||
return &Adapter{
|
||||
config: config,
|
||||
selfID: selfID,
|
||||
apiClient: NewAPIClient(config.ProtocolURL, config.AccessToken, timeout, retryCount, logger),
|
||||
eventBus: eventBus,
|
||||
wsManager: wsManager,
|
||||
eventConverter: NewEventConverter(logger),
|
||||
logger: logger.Named("milky-adapter"),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 连接到协议端
|
||||
func (a *Adapter) Connect(ctx context.Context) error {
|
||||
a.logger.Info("Connecting to Milky protocol server",
|
||||
zap.String("url", a.config.ProtocolURL),
|
||||
zap.String("event_mode", a.config.EventMode))
|
||||
|
||||
// 根据配置选择事件接收方式
|
||||
switch a.config.EventMode {
|
||||
case "sse":
|
||||
return a.connectSSE(ctx)
|
||||
case "websocket":
|
||||
return a.connectWebSocket(ctx)
|
||||
case "webhook":
|
||||
return a.startWebhook()
|
||||
default:
|
||||
return fmt.Errorf("unknown event mode: %s", a.config.EventMode)
|
||||
}
|
||||
}
|
||||
|
||||
// connectSSE 连接 SSE
|
||||
func (a *Adapter) connectSSE(ctx context.Context) error {
|
||||
eventURL := a.config.ProtocolURL + "/event"
|
||||
|
||||
// 创建 SSE 客户端配置
|
||||
sseConfig := net.SSEClientConfig{
|
||||
URL: eventURL,
|
||||
AccessToken: a.config.AccessToken,
|
||||
ReconnectDelay: 5 * time.Second,
|
||||
MaxReconnect: -1, // 无限重连
|
||||
EventFilter: "milky_event", // 只接收 milky_event 类型
|
||||
BufferSize: 100,
|
||||
}
|
||||
|
||||
a.sseClient = net.NewSSEClient(sseConfig, a.logger)
|
||||
|
||||
// 启动 SSE 连接
|
||||
if err := a.sseClient.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("failed to connect SSE: %w", err)
|
||||
}
|
||||
|
||||
// 启动事件处理
|
||||
go a.handleEvents(a.sseClient.Events())
|
||||
|
||||
a.logger.Info("SSE connection established")
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectWebSocket 连接 WebSocket
|
||||
func (a *Adapter) connectWebSocket(ctx context.Context) error {
|
||||
// 构建 WebSocket URL
|
||||
eventURL := a.config.ProtocolURL + "/event"
|
||||
// 替换 http:// 为 ws://,https:// 为 wss://
|
||||
if len(eventURL) > 7 && eventURL[:7] == "http://" {
|
||||
eventURL = "ws://" + eventURL[7:]
|
||||
} else if len(eventURL) > 8 && eventURL[:8] == "https://" {
|
||||
eventURL = "wss://" + eventURL[8:]
|
||||
}
|
||||
|
||||
// 添加 access_token 参数
|
||||
if a.config.AccessToken != "" {
|
||||
eventURL += "?access_token=" + a.config.AccessToken
|
||||
}
|
||||
|
||||
a.logger.Info("Connecting to WebSocket", zap.String("url", eventURL))
|
||||
|
||||
// 使用 WebSocketManager 建立连接
|
||||
conn, err := a.wsManager.Dial(eventURL, a.selfID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial WebSocket: %w", err)
|
||||
}
|
||||
|
||||
a.wsConn = conn
|
||||
|
||||
// 启动事件处理
|
||||
go a.handleWebSocketEvents()
|
||||
|
||||
a.logger.Info("WebSocket connection established")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWebSocketEvents 处理 WebSocket 事件
|
||||
func (a *Adapter) handleWebSocketEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// 读取消息
|
||||
_, message, err := a.wsConn.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to read WebSocket message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换事件
|
||||
event, err := a.eventConverter.Convert(message)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to convert event", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 发布到事件总线
|
||||
a.eventBus.Publish(event)
|
||||
}
|
||||
}
|
||||
|
||||
// startWebhook 启动 Webhook 服务器
|
||||
func (a *Adapter) startWebhook() error {
|
||||
if a.config.WebhookListenAddr == "" {
|
||||
return fmt.Errorf("webhook_listen_addr is required for webhook mode")
|
||||
}
|
||||
|
||||
a.webhookServer = NewWebhookServer(a.config.WebhookListenAddr, a.logger)
|
||||
|
||||
// 启动服务器
|
||||
if err := a.webhookServer.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start webhook server: %w", err)
|
||||
}
|
||||
|
||||
// 启动事件处理
|
||||
go a.handleEvents(a.webhookServer.Events())
|
||||
|
||||
a.logger.Info("Webhook server started", zap.String("addr", a.config.WebhookListenAddr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleEvents 处理事件
|
||||
func (a *Adapter) handleEvents(eventChan <-chan []byte) {
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case rawEvent, ok := <-eventChan:
|
||||
if !ok {
|
||||
a.logger.Info("Event channel closed")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换事件
|
||||
event, err := a.eventConverter.Convert(rawEvent)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to convert event", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 发布到事件总线
|
||||
a.eventBus.Publish(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendAction 发送动作
|
||||
func (a *Adapter) SendAction(ctx context.Context, action protocol.Action) (map[string]interface{}, error) {
|
||||
// 调用 API
|
||||
resp, err := a.apiClient.Call(ctx, string(action.GetType()), action.GetParams())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call API: %w", err)
|
||||
}
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// ParseMessage 解析消息
|
||||
func (a *Adapter) ParseMessage(raw []byte) (protocol.Event, error) {
|
||||
return a.eventConverter.Convert(raw)
|
||||
}
|
||||
|
||||
// Disconnect 断开连接
|
||||
func (a *Adapter) Disconnect() error {
|
||||
a.logger.Info("Disconnecting from Milky protocol server")
|
||||
|
||||
a.cancel()
|
||||
|
||||
// 关闭各种连接
|
||||
if a.sseClient != nil {
|
||||
if err := a.sseClient.Close(); err != nil {
|
||||
a.logger.Error("Failed to close SSE client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if a.wsConn != nil {
|
||||
// WebSocket 连接会在 context 取消时自动关闭
|
||||
a.logger.Info("WebSocket connection will be closed")
|
||||
}
|
||||
|
||||
if a.webhookServer != nil {
|
||||
if err := a.webhookServer.Stop(); err != nil {
|
||||
a.logger.Error("Failed to stop webhook server", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if a.apiClient != nil {
|
||||
if err := a.apiClient.Close(); err != nil {
|
||||
a.logger.Error("Failed to close API client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProtocolName 获取协议名称
|
||||
func (a *Adapter) GetProtocolName() string {
|
||||
return "milky"
|
||||
}
|
||||
|
||||
// GetProtocolVersion 获取协议版本
|
||||
func (a *Adapter) GetProtocolVersion() string {
|
||||
return "1.0"
|
||||
}
|
||||
|
||||
// GetSelfID 获取机器人自身 ID
|
||||
func (a *Adapter) GetSelfID() string {
|
||||
return a.selfID
|
||||
}
|
||||
|
||||
// IsConnected 是否已连接
|
||||
func (a *Adapter) IsConnected() bool {
|
||||
switch a.config.EventMode {
|
||||
case "sse":
|
||||
return a.sseClient != nil
|
||||
case "websocket":
|
||||
return a.wsConn != nil && a.wsConn.Conn != nil
|
||||
case "webhook":
|
||||
return a.webhookServer != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (a *Adapter) GetStats() map[string]interface{} {
|
||||
stats := map[string]interface{}{
|
||||
"protocol": "milky",
|
||||
"self_id": a.selfID,
|
||||
"event_mode": a.config.EventMode,
|
||||
"connected": a.IsConnected(),
|
||||
}
|
||||
|
||||
if a.config.EventMode == "websocket" && a.wsConn != nil {
|
||||
stats["remote_addr"] = a.wsConn.RemoteAddr
|
||||
stats["connection_type"] = a.wsConn.Type
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// CallAPI 直接调用 API(提供给 Bot 使用)
|
||||
func (a *Adapter) CallAPI(ctx context.Context, endpoint string, params map[string]interface{}) (*APIResponse, error) {
|
||||
return a.apiClient.Call(ctx, endpoint, params)
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (a *Adapter) GetConfig() *Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
// SetSelfID 设置机器人自身 ID
|
||||
func (a *Adapter) SetSelfID(selfID string) {
|
||||
a.selfID = selfID
|
||||
}
|
||||
|
||||
// GetSelfIDInt64 获取机器人自身 ID(int64)
|
||||
func (a *Adapter) GetSelfIDInt64() (int64, error) {
|
||||
return strconv.ParseInt(a.selfID, 10, 64)
|
||||
}
|
||||
189
internal/adapter/milky/api_client.go
Normal file
189
internal/adapter/milky/api_client.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// APIClient Milky API 客户端
|
||||
// 用于调用协议端的 API (POST /api/:api)
|
||||
type APIClient struct {
|
||||
baseURL string
|
||||
accessToken string
|
||||
httpClient *fasthttp.Client
|
||||
logger *zap.Logger
|
||||
timeout time.Duration
|
||||
retryCount int
|
||||
}
|
||||
|
||||
// NewAPIClient 创建 API 客户端
|
||||
func NewAPIClient(baseURL, accessToken string, timeout time.Duration, retryCount int, logger *zap.Logger) *APIClient {
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if retryCount == 0 {
|
||||
retryCount = 3
|
||||
}
|
||||
|
||||
return &APIClient{
|
||||
baseURL: baseURL,
|
||||
accessToken: accessToken,
|
||||
httpClient: &fasthttp.Client{
|
||||
ReadTimeout: timeout,
|
||||
WriteTimeout: timeout,
|
||||
MaxConnsPerHost: 100,
|
||||
},
|
||||
logger: logger.Named("api-client"),
|
||||
timeout: timeout,
|
||||
retryCount: retryCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Call 调用 API
|
||||
// endpoint: API 端点名称(如 "send_private_message")
|
||||
// input: 输入参数(会被序列化为 JSON)
|
||||
// 返回: 响应数据和错误
|
||||
func (c *APIClient) Call(ctx context.Context, endpoint string, input interface{}) (*APIResponse, error) {
|
||||
// 序列化输入参数
|
||||
var inputData []byte
|
||||
var err error
|
||||
|
||||
if input == nil {
|
||||
inputData = []byte("{}")
|
||||
} else {
|
||||
inputData, err = sonic.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 URL
|
||||
url := fmt.Sprintf("%s/api/%s", c.baseURL, endpoint)
|
||||
|
||||
c.logger.Debug("Calling API",
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("url", url))
|
||||
|
||||
// 重试机制
|
||||
var lastErr error
|
||||
for i := 0; i <= c.retryCount; i++ {
|
||||
if i > 0 {
|
||||
c.logger.Info("Retrying API call",
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.Int("attempt", i),
|
||||
zap.Int("max", c.retryCount))
|
||||
|
||||
// 指数退避
|
||||
backoff := time.Duration(i) * time.Second
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, url, inputData)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("API call failed after %d retries: %w", c.retryCount, lastErr)
|
||||
}
|
||||
|
||||
// doRequest 执行单次请求
|
||||
func (c *APIClient) doRequest(ctx context.Context, url string, inputData []byte) (*APIResponse, error) {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
// 设置请求
|
||||
req.SetRequestURI(url)
|
||||
req.Header.SetMethod("POST")
|
||||
req.Header.SetContentType("application/json")
|
||||
req.SetBody(inputData)
|
||||
|
||||
// 设置 Authorization 头
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.accessToken))
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
err := c.httpClient.DoTimeout(req, resp, c.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
statusCode := resp.StatusCode()
|
||||
switch statusCode {
|
||||
case 401:
|
||||
return nil, fmt.Errorf("unauthorized: access token invalid or missing")
|
||||
case 404:
|
||||
return nil, fmt.Errorf("API not found: %s", url)
|
||||
case 415:
|
||||
return nil, fmt.Errorf("unsupported media type: Content-Type must be application/json")
|
||||
case 200:
|
||||
// 继续处理
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected status code: %d", statusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var apiResp APIResponse
|
||||
if err := sonic.Unmarshal(resp.Body(), &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// 检查业务状态
|
||||
if apiResp.Status != "ok" {
|
||||
c.logger.Warn("API call failed",
|
||||
zap.String("status", apiResp.Status),
|
||||
zap.Int("retcode", apiResp.RetCode),
|
||||
zap.String("message", apiResp.Message))
|
||||
|
||||
return &apiResp, fmt.Errorf("API error (retcode=%d): %s", apiResp.RetCode, apiResp.Message)
|
||||
}
|
||||
|
||||
c.logger.Debug("API call succeeded",
|
||||
zap.String("status", apiResp.Status),
|
||||
zap.Int("retcode", apiResp.RetCode))
|
||||
|
||||
return &apiResp, nil
|
||||
}
|
||||
|
||||
// CallWithoutRetry 调用 API(不重试)
|
||||
func (c *APIClient) CallWithoutRetry(ctx context.Context, endpoint string, input interface{}) (*APIResponse, error) {
|
||||
// 序列化输入参数
|
||||
var inputData []byte
|
||||
var err error
|
||||
|
||||
if input == nil {
|
||||
inputData = []byte("{}")
|
||||
} else {
|
||||
inputData, err = sonic.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 URL
|
||||
url := fmt.Sprintf("%s/api/%s", c.baseURL, endpoint)
|
||||
|
||||
return c.doRequest(ctx, url, inputData)
|
||||
}
|
||||
|
||||
// Close 关闭客户端
|
||||
func (c *APIClient) Close() error {
|
||||
// fasthttp.Client 不需要显式关闭
|
||||
return nil
|
||||
}
|
||||
321
internal/adapter/milky/bot.go
Normal file
321
internal/adapter/milky/bot.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"cellbot/internal/engine"
|
||||
"cellbot/internal/protocol"
|
||||
"cellbot/pkg/net"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Bot Milky Bot 实例
|
||||
type Bot struct {
|
||||
id string
|
||||
adapter *Adapter
|
||||
logger *zap.Logger
|
||||
status protocol.BotStatus
|
||||
}
|
||||
|
||||
// NewBot 创建 Milky Bot 实例
|
||||
func NewBot(id string, config *Config, eventBus *engine.EventBus, wsManager *net.WebSocketManager, logger *zap.Logger) *Bot {
|
||||
adapter := NewAdapter(config, id, eventBus, wsManager, logger)
|
||||
|
||||
return &Bot{
|
||||
id: id,
|
||||
adapter: adapter,
|
||||
logger: logger.Named("milky-bot").With(zap.String("bot_id", id)),
|
||||
status: protocol.BotStatusStopped,
|
||||
}
|
||||
}
|
||||
|
||||
// GetID 获取机器人 ID
|
||||
func (b *Bot) GetID() string {
|
||||
return b.id
|
||||
}
|
||||
|
||||
// GetProtocol 获取协议名称
|
||||
func (b *Bot) GetProtocol() string {
|
||||
return "milky"
|
||||
}
|
||||
|
||||
// Name 获取协议名称
|
||||
func (b *Bot) Name() string {
|
||||
return "milky"
|
||||
}
|
||||
|
||||
// Version 获取协议版本
|
||||
func (b *Bot) Version() string {
|
||||
return "1.0"
|
||||
}
|
||||
|
||||
// GetSelfID 获取机器人自身ID
|
||||
func (b *Bot) GetSelfID() string {
|
||||
return b.id
|
||||
}
|
||||
|
||||
// Start 启动实例
|
||||
func (b *Bot) Start(ctx context.Context) error {
|
||||
return b.Connect(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止实例
|
||||
func (b *Bot) Stop(ctx context.Context) error {
|
||||
return b.Disconnect(ctx)
|
||||
}
|
||||
|
||||
// HandleEvent 处理事件
|
||||
func (b *Bot) HandleEvent(ctx context.Context, event protocol.Event) error {
|
||||
// Milky 适配器通过事件总线自动处理事件
|
||||
// 这里不需要额外处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus 获取状态
|
||||
func (b *Bot) GetStatus() protocol.BotStatus {
|
||||
return b.status
|
||||
}
|
||||
|
||||
// Connect 连接
|
||||
func (b *Bot) Connect(ctx context.Context) error {
|
||||
b.logger.Info("Connecting Milky bot")
|
||||
|
||||
if err := b.adapter.Connect(ctx); err != nil {
|
||||
b.status = protocol.BotStatusError
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
b.status = protocol.BotStatusRunning
|
||||
b.logger.Info("Milky bot connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect 断开连接
|
||||
func (b *Bot) Disconnect(ctx context.Context) error {
|
||||
b.logger.Info("Disconnecting Milky bot")
|
||||
|
||||
if err := b.adapter.Disconnect(); err != nil {
|
||||
return fmt.Errorf("failed to disconnect: %w", err)
|
||||
}
|
||||
|
||||
b.status = protocol.BotStatusStopped
|
||||
b.logger.Info("Milky bot disconnected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAction 发送动作
|
||||
func (b *Bot) SendAction(ctx context.Context, action protocol.Action) (map[string]interface{}, error) {
|
||||
if b.status != protocol.BotStatusRunning {
|
||||
return nil, fmt.Errorf("bot is not running")
|
||||
}
|
||||
|
||||
return b.adapter.SendAction(ctx, action)
|
||||
}
|
||||
|
||||
// GetAdapter 获取适配器
|
||||
func (b *Bot) GetAdapter() *Adapter {
|
||||
return b.adapter
|
||||
}
|
||||
|
||||
// GetInfo 获取机器人信息
|
||||
func (b *Bot) GetInfo() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": b.id,
|
||||
"protocol": "milky",
|
||||
"status": b.status,
|
||||
"stats": b.adapter.GetStats(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnected 是否已连接
|
||||
func (b *Bot) IsConnected() bool {
|
||||
return b.status == protocol.BotStatusRunning && b.adapter.IsConnected()
|
||||
}
|
||||
|
||||
// SetStatus 设置状态
|
||||
func (b *Bot) SetStatus(status protocol.BotStatus) {
|
||||
b.status = status
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Milky 特定的 API 方法
|
||||
// ============================================================================
|
||||
|
||||
// SendPrivateMessage 发送私聊消息
|
||||
func (b *Bot) SendPrivateMessage(ctx context.Context, userID int64, segments []OutgoingSegment) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"segments": segments,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "send_private_message", params)
|
||||
}
|
||||
|
||||
// SendGroupMessage 发送群消息
|
||||
func (b *Bot) SendGroupMessage(ctx context.Context, groupID int64, segments []OutgoingSegment) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"segments": segments,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "send_group_message", params)
|
||||
}
|
||||
|
||||
// SendTempMessage 发送临时消息
|
||||
func (b *Bot) SendTempMessage(ctx context.Context, groupID, userID int64, segments []OutgoingSegment) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
"segments": segments,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "send_temp_message", params)
|
||||
}
|
||||
|
||||
// RecallMessage 撤回消息
|
||||
func (b *Bot) RecallMessage(ctx context.Context, messageScene string, peerID, messageSeq int64) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"message_scene": messageScene,
|
||||
"peer_id": peerID,
|
||||
"message_seq": messageSeq,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "recall_message", params)
|
||||
}
|
||||
|
||||
// GetFriendList 获取好友列表
|
||||
func (b *Bot) GetFriendList(ctx context.Context) (*APIResponse, error) {
|
||||
return b.adapter.CallAPI(ctx, "get_friend_list", nil)
|
||||
}
|
||||
|
||||
// GetGroupList 获取群列表
|
||||
func (b *Bot) GetGroupList(ctx context.Context) (*APIResponse, error) {
|
||||
return b.adapter.CallAPI(ctx, "get_group_list", nil)
|
||||
}
|
||||
|
||||
// GetGroupMemberList 获取群成员列表
|
||||
func (b *Bot) GetGroupMemberList(ctx context.Context, groupID int64) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "get_group_member_list", params)
|
||||
}
|
||||
|
||||
// GetGroupMemberInfo 获取群成员信息
|
||||
func (b *Bot) GetGroupMemberInfo(ctx context.Context, groupID, userID int64) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "get_group_member_info", params)
|
||||
}
|
||||
|
||||
// SetGroupAdmin 设置群管理员
|
||||
func (b *Bot) SetGroupAdmin(ctx context.Context, groupID, userID int64, isSet bool) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
"is_set": isSet,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "set_group_admin", params)
|
||||
}
|
||||
|
||||
// SetGroupCard 设置群名片
|
||||
func (b *Bot) SetGroupCard(ctx context.Context, groupID, userID int64, card string) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
"card": card,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "set_group_card", params)
|
||||
}
|
||||
|
||||
// SetGroupName 设置群名
|
||||
func (b *Bot) SetGroupName(ctx context.Context, groupID int64, groupName string) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"group_name": groupName,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "set_group_name", params)
|
||||
}
|
||||
|
||||
// KickGroupMember 踢出群成员
|
||||
func (b *Bot) KickGroupMember(ctx context.Context, groupID, userID int64, rejectAddRequest bool) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
"reject_add_request": rejectAddRequest,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "kick_group_member", params)
|
||||
}
|
||||
|
||||
// MuteGroupMember 禁言群成员
|
||||
func (b *Bot) MuteGroupMember(ctx context.Context, groupID, userID int64, duration int32) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
"duration": duration,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "mute_group_member", params)
|
||||
}
|
||||
|
||||
// MuteGroupWhole 全体禁言
|
||||
func (b *Bot) MuteGroupWhole(ctx context.Context, groupID int64, isMute bool) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"is_mute": isMute,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "mute_group_whole", params)
|
||||
}
|
||||
|
||||
// LeaveGroup 退出群
|
||||
func (b *Bot) LeaveGroup(ctx context.Context, groupID int64) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "leave_group", params)
|
||||
}
|
||||
|
||||
// HandleFriendRequest 处理好友请求
|
||||
func (b *Bot) HandleFriendRequest(ctx context.Context, initiatorUID string, accept bool) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"initiator_uid": initiatorUID,
|
||||
"accept": accept,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "handle_friend_request", params)
|
||||
}
|
||||
|
||||
// HandleGroupJoinRequest 处理入群申请
|
||||
func (b *Bot) HandleGroupJoinRequest(ctx context.Context, groupID, notificationSeq int64, accept bool, rejectReason string) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"notification_seq": notificationSeq,
|
||||
"accept": accept,
|
||||
"reject_reason": rejectReason,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "handle_group_join_request", params)
|
||||
}
|
||||
|
||||
// HandleGroupInvitation 处理群邀请
|
||||
func (b *Bot) HandleGroupInvitation(ctx context.Context, groupID, invitationSeq int64, accept bool) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"invitation_seq": invitationSeq,
|
||||
"accept": accept,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "handle_group_invitation", params)
|
||||
}
|
||||
|
||||
// UploadFile 上传文件
|
||||
func (b *Bot) UploadFile(ctx context.Context, fileType, filePath string) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"file_path": filePath,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "upload_file", params)
|
||||
}
|
||||
|
||||
// GetFile 获取文件
|
||||
func (b *Bot) GetFile(ctx context.Context, fileID string) (*APIResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"file_id": fileID,
|
||||
}
|
||||
return b.adapter.CallAPI(ctx, "get_file", params)
|
||||
}
|
||||
693
internal/adapter/milky/event.go
Normal file
693
internal/adapter/milky/event.go
Normal file
@@ -0,0 +1,693 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"cellbot/internal/protocol"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// EventConverter 事件转换器
|
||||
// 将 Milky 事件转换为通用 protocol.Event
|
||||
type EventConverter struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewEventConverter 创建事件转换器
|
||||
func NewEventConverter(logger *zap.Logger) *EventConverter {
|
||||
return &EventConverter{
|
||||
logger: logger.Named("event-converter"),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 转换事件
|
||||
func (c *EventConverter) Convert(raw []byte) (protocol.Event, error) {
|
||||
// 解析原始事件
|
||||
var milkyEvent Event
|
||||
if err := sonic.Unmarshal(raw, &milkyEvent); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal event: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("Converting event",
|
||||
zap.String("event_type", milkyEvent.EventType),
|
||||
zap.Int64("self_id", milkyEvent.SelfID))
|
||||
|
||||
// 根据事件类型转换
|
||||
switch milkyEvent.EventType {
|
||||
case EventTypeMessageReceive:
|
||||
return c.convertMessageEvent(&milkyEvent)
|
||||
case EventTypeFriendRequest:
|
||||
return c.convertFriendRequestEvent(&milkyEvent)
|
||||
case EventTypeGroupJoinRequest:
|
||||
return c.convertGroupJoinRequestEvent(&milkyEvent)
|
||||
case EventTypeGroupInvitedJoinRequest:
|
||||
return c.convertGroupInvitedJoinRequestEvent(&milkyEvent)
|
||||
case EventTypeGroupInvitation:
|
||||
return c.convertGroupInvitationEvent(&milkyEvent)
|
||||
case EventTypeMessageRecall:
|
||||
return c.convertMessageRecallEvent(&milkyEvent)
|
||||
case EventTypeBotOffline:
|
||||
return c.convertBotOfflineEvent(&milkyEvent)
|
||||
case EventTypeFriendNudge:
|
||||
return c.convertFriendNudgeEvent(&milkyEvent)
|
||||
case EventTypeFriendFileUpload:
|
||||
return c.convertFriendFileUploadEvent(&milkyEvent)
|
||||
case EventTypeGroupAdminChange:
|
||||
return c.convertGroupAdminChangeEvent(&milkyEvent)
|
||||
case EventTypeGroupEssenceMessageChange:
|
||||
return c.convertGroupEssenceMessageChangeEvent(&milkyEvent)
|
||||
case EventTypeGroupMemberIncrease:
|
||||
return c.convertGroupMemberIncreaseEvent(&milkyEvent)
|
||||
case EventTypeGroupMemberDecrease:
|
||||
return c.convertGroupMemberDecreaseEvent(&milkyEvent)
|
||||
case EventTypeGroupNameChange:
|
||||
return c.convertGroupNameChangeEvent(&milkyEvent)
|
||||
case EventTypeGroupMessageReaction:
|
||||
return c.convertGroupMessageReactionEvent(&milkyEvent)
|
||||
case EventTypeGroupMute:
|
||||
return c.convertGroupMuteEvent(&milkyEvent)
|
||||
case EventTypeGroupWholeMute:
|
||||
return c.convertGroupWholeMuteEvent(&milkyEvent)
|
||||
case EventTypeGroupNudge:
|
||||
return c.convertGroupNudgeEvent(&milkyEvent)
|
||||
case EventTypeGroupFileUpload:
|
||||
return c.convertGroupFileUploadEvent(&milkyEvent)
|
||||
default:
|
||||
c.logger.Warn("Unknown event type", zap.String("event_type", milkyEvent.EventType))
|
||||
return nil, fmt.Errorf("unknown event type: %s", milkyEvent.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
// convertMessageEvent 转换消息事件
|
||||
func (c *EventConverter) convertMessageEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
// 解析消息数据
|
||||
var msgData IncomingMessage
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &msgData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message data: %w", err)
|
||||
}
|
||||
|
||||
// 构建消息文本
|
||||
messageText := c.buildMessageText(msgData.Segments)
|
||||
|
||||
event := &protocol.MessageEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeMessage,
|
||||
DetailType: msgData.MessageScene, // friend, group, temp
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"peer_id": msgData.PeerID,
|
||||
"message_seq": msgData.MessageSeq,
|
||||
"sender_id": msgData.SenderID,
|
||||
"time": msgData.Time,
|
||||
"segments": msgData.Segments,
|
||||
},
|
||||
},
|
||||
MessageID: strconv.FormatInt(msgData.MessageSeq, 10),
|
||||
Message: messageText,
|
||||
AltText: messageText,
|
||||
}
|
||||
|
||||
// 添加场景特定数据
|
||||
if msgData.Friend != nil {
|
||||
event.Data["friend"] = msgData.Friend
|
||||
}
|
||||
if msgData.Group != nil {
|
||||
event.Data["group"] = msgData.Group
|
||||
}
|
||||
if msgData.GroupMember != nil {
|
||||
event.Data["group_member"] = msgData.GroupMember
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertFriendRequestEvent 转换好友请求事件
|
||||
func (c *EventConverter) convertFriendRequestEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data FriendRequestEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse friend request data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.RequestEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeRequest,
|
||||
DetailType: "friend",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"initiator_id": data.InitiatorID,
|
||||
"initiator_uid": data.InitiatorUID,
|
||||
"comment": data.Comment,
|
||||
"via": data.Via,
|
||||
},
|
||||
},
|
||||
RequestID: strconv.FormatInt(data.InitiatorID, 10),
|
||||
UserID: strconv.FormatInt(data.InitiatorID, 10),
|
||||
Comment: data.Comment,
|
||||
Flag: data.InitiatorUID,
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupJoinRequestEvent 转换入群申请事件
|
||||
func (c *EventConverter) convertGroupJoinRequestEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupJoinRequestEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group join request data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.RequestEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeRequest,
|
||||
DetailType: "group",
|
||||
SubType: "add",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"group_id": data.GroupID,
|
||||
"notification_seq": data.NotificationSeq,
|
||||
"is_filtered": data.IsFiltered,
|
||||
"initiator_id": data.InitiatorID,
|
||||
"comment": data.Comment,
|
||||
},
|
||||
},
|
||||
RequestID: strconv.FormatInt(data.NotificationSeq, 10),
|
||||
UserID: strconv.FormatInt(data.InitiatorID, 10),
|
||||
Comment: data.Comment,
|
||||
Flag: strconv.FormatInt(data.NotificationSeq, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupInvitedJoinRequestEvent 转换群成员邀请他人入群事件
|
||||
func (c *EventConverter) convertGroupInvitedJoinRequestEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupInvitedJoinRequestEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group invited join request data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.RequestEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeRequest,
|
||||
DetailType: "group",
|
||||
SubType: "invite",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"group_id": data.GroupID,
|
||||
"notification_seq": data.NotificationSeq,
|
||||
"initiator_id": data.InitiatorID,
|
||||
"target_user_id": data.TargetUserID,
|
||||
},
|
||||
},
|
||||
RequestID: strconv.FormatInt(data.NotificationSeq, 10),
|
||||
UserID: strconv.FormatInt(data.InitiatorID, 10),
|
||||
Comment: "",
|
||||
Flag: strconv.FormatInt(data.NotificationSeq, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupInvitationEvent 转换他人邀请自身入群事件
|
||||
func (c *EventConverter) convertGroupInvitationEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupInvitationEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group invitation data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.RequestEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeRequest,
|
||||
DetailType: "group",
|
||||
SubType: "invite_self",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"group_id": data.GroupID,
|
||||
"invitation_seq": data.InvitationSeq,
|
||||
"initiator_id": data.InitiatorID,
|
||||
},
|
||||
},
|
||||
RequestID: strconv.FormatInt(data.InvitationSeq, 10),
|
||||
UserID: strconv.FormatInt(data.InitiatorID, 10),
|
||||
Comment: "",
|
||||
Flag: strconv.FormatInt(data.InvitationSeq, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertMessageRecallEvent 转换消息撤回事件
|
||||
func (c *EventConverter) convertMessageRecallEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data MessageRecallEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message recall data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "message_recall",
|
||||
SubType: data.MessageScene,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"message_scene": data.MessageScene,
|
||||
"peer_id": data.PeerID,
|
||||
"message_seq": data.MessageSeq,
|
||||
"sender_id": data.SenderID,
|
||||
"operator_id": data.OperatorID,
|
||||
"display_suffix": data.DisplaySuffix,
|
||||
},
|
||||
},
|
||||
UserID: strconv.FormatInt(data.SenderID, 10),
|
||||
Operator: strconv.FormatInt(data.OperatorID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertBotOfflineEvent 转换机器人离线事件
|
||||
func (c *EventConverter) convertBotOfflineEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data BotOfflineEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse bot offline data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.MetaEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeMeta,
|
||||
DetailType: "bot_offline",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}{
|
||||
"reason": data.Reason,
|
||||
},
|
||||
},
|
||||
Status: "offline",
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertFriendNudgeEvent 转换好友戳一戳事件
|
||||
func (c *EventConverter) convertFriendNudgeEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data FriendNudgeEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse friend nudge data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "friend_nudge",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertFriendFileUploadEvent 转换好友文件上传事件
|
||||
func (c *EventConverter) convertFriendFileUploadEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data FriendFileUploadEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse friend file upload data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "friend_file_upload",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupAdminChangeEvent 转换群管理员变更事件
|
||||
func (c *EventConverter) convertGroupAdminChangeEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupAdminChangeEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group admin change data: %w", err)
|
||||
}
|
||||
|
||||
subType := "unset"
|
||||
if data.IsSet {
|
||||
subType = "set"
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_admin",
|
||||
SubType: subType,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupEssenceMessageChangeEvent 转换群精华消息变更事件
|
||||
func (c *EventConverter) convertGroupEssenceMessageChangeEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupEssenceMessageChangeEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group essence message change data: %w", err)
|
||||
}
|
||||
|
||||
subType := "delete"
|
||||
if data.IsSet {
|
||||
subType = "add"
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_essence",
|
||||
SubType: subType,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupMemberIncreaseEvent 转换群成员增加事件
|
||||
func (c *EventConverter) convertGroupMemberIncreaseEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupMemberIncreaseEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group member increase data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_increase",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
if data.OperatorID != nil {
|
||||
event.Operator = strconv.FormatInt(*data.OperatorID, 10)
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupMemberDecreaseEvent 转换群成员减少事件
|
||||
func (c *EventConverter) convertGroupMemberDecreaseEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupMemberDecreaseEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group member decrease data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_decrease",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
if data.OperatorID != nil {
|
||||
event.Operator = strconv.FormatInt(*data.OperatorID, 10)
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupNameChangeEvent 转换群名称变更事件
|
||||
func (c *EventConverter) convertGroupNameChangeEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupNameChangeEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group name change data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_name_change",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
Operator: strconv.FormatInt(data.OperatorID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupMessageReactionEvent 转换群消息回应事件
|
||||
func (c *EventConverter) convertGroupMessageReactionEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupMessageReactionEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group message reaction data: %w", err)
|
||||
}
|
||||
|
||||
subType := "remove"
|
||||
if data.IsAdd {
|
||||
subType = "add"
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_message_reaction",
|
||||
SubType: subType,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupMuteEvent 转换群禁言事件
|
||||
func (c *EventConverter) convertGroupMuteEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupMuteEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group mute data: %w", err)
|
||||
}
|
||||
|
||||
subType := "ban"
|
||||
if data.Duration == 0 {
|
||||
subType = "lift_ban"
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_ban",
|
||||
SubType: subType,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
Operator: strconv.FormatInt(data.OperatorID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupWholeMuteEvent 转换群全体禁言事件
|
||||
func (c *EventConverter) convertGroupWholeMuteEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupWholeMuteEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group whole mute data: %w", err)
|
||||
}
|
||||
|
||||
subType := "ban"
|
||||
if !data.IsMute {
|
||||
subType = "lift_ban"
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_whole_ban",
|
||||
SubType: subType,
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
Operator: strconv.FormatInt(data.OperatorID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupNudgeEvent 转换群戳一戳事件
|
||||
func (c *EventConverter) convertGroupNudgeEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupNudgeEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group nudge data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_nudge",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.SenderID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// convertGroupFileUploadEvent 转换群文件上传事件
|
||||
func (c *EventConverter) convertGroupFileUploadEvent(milkyEvent *Event) (protocol.Event, error) {
|
||||
selfID := strconv.FormatInt(milkyEvent.SelfID, 10)
|
||||
|
||||
var data GroupFileUploadEventData
|
||||
dataBytes, _ := sonic.Marshal(milkyEvent.Data)
|
||||
if err := sonic.Unmarshal(dataBytes, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group file upload data: %w", err)
|
||||
}
|
||||
|
||||
event := &protocol.NoticeEvent{
|
||||
BaseEvent: protocol.BaseEvent{
|
||||
Type: protocol.EventTypeNotice,
|
||||
DetailType: "group_file_upload",
|
||||
SubType: "",
|
||||
Timestamp: milkyEvent.Time,
|
||||
SelfID: selfID,
|
||||
Data: map[string]interface{}(milkyEvent.Data),
|
||||
},
|
||||
GroupID: strconv.FormatInt(data.GroupID, 10),
|
||||
UserID: strconv.FormatInt(data.UserID, 10),
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// buildMessageText 构建消息文本
|
||||
func (c *EventConverter) buildMessageText(segments []IncomingSegment) string {
|
||||
var text string
|
||||
for _, seg := range segments {
|
||||
if seg.Type == "text" {
|
||||
if textData, ok := seg.Data["text"].(string); ok {
|
||||
text += textData
|
||||
}
|
||||
} else if seg.Type == "mention" {
|
||||
if userID, ok := seg.Data["user_id"].(float64); ok {
|
||||
text += fmt.Sprintf("@%d", int64(userID))
|
||||
}
|
||||
} else if seg.Type == "image" {
|
||||
text += "[图片]"
|
||||
} else if seg.Type == "voice" {
|
||||
text += "[语音]"
|
||||
} else if seg.Type == "video" {
|
||||
text += "[视频]"
|
||||
} else if seg.Type == "file" {
|
||||
text += "[文件]"
|
||||
} else if seg.Type == "face" {
|
||||
text += "[表情]"
|
||||
} else if seg.Type == "forward" {
|
||||
text += "[转发消息]"
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
240
internal/adapter/milky/sse_client.go
Normal file
240
internal/adapter/milky/sse_client.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SSEClient Server-Sent Events 客户端
|
||||
// 用于接收协议端推送的事件 (GET /event)
|
||||
type SSEClient struct {
|
||||
url string
|
||||
accessToken string
|
||||
eventChan chan []byte
|
||||
logger *zap.Logger
|
||||
reconnectDelay time.Duration
|
||||
maxReconnect int
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewSSEClient 创建 SSE 客户端
|
||||
func NewSSEClient(url, accessToken string, logger *zap.Logger) *SSEClient {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &SSEClient{
|
||||
url: url,
|
||||
accessToken: accessToken,
|
||||
eventChan: make(chan []byte, 100),
|
||||
logger: logger.Named("sse-client"),
|
||||
reconnectDelay: 5 * time.Second,
|
||||
maxReconnect: -1, // 无限重连
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 连接到 SSE 服务器
|
||||
func (c *SSEClient) Connect(ctx context.Context) error {
|
||||
c.logger.Info("Starting SSE client", zap.String("url", c.url))
|
||||
|
||||
go c.connectLoop(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectLoop 连接循环(支持自动重连)
|
||||
func (c *SSEClient) connectLoop(ctx context.Context) {
|
||||
reconnectCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.logger.Info("SSE client stopped")
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
c.logger.Info("SSE client stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
c.logger.Info("Connecting to SSE server",
|
||||
zap.String("url", c.url),
|
||||
zap.Int("reconnect_count", reconnectCount))
|
||||
|
||||
err := c.connect(ctx)
|
||||
if err != nil {
|
||||
c.logger.Error("SSE connection failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 检查是否需要重连
|
||||
if c.maxReconnect >= 0 && reconnectCount >= c.maxReconnect {
|
||||
c.logger.Error("Max reconnect attempts reached", zap.Int("count", reconnectCount))
|
||||
return
|
||||
}
|
||||
|
||||
reconnectCount++
|
||||
|
||||
// 等待后重连
|
||||
c.logger.Info("Reconnecting after delay",
|
||||
zap.Duration("delay", c.reconnectDelay),
|
||||
zap.Int("attempt", reconnectCount))
|
||||
|
||||
select {
|
||||
case <-time.After(c.reconnectDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connect 建立单次连接
|
||||
func (c *SSEClient) connect(ctx context.Context) error {
|
||||
// 创建 HTTP 请求
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// 设置 Authorization 头
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.accessToken))
|
||||
}
|
||||
|
||||
// 设置 Accept 头
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
Timeout: 0, // 无超时,保持长连接
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 检查 Content-Type
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "text/event-stream") {
|
||||
return fmt.Errorf("unexpected content type: %s", contentType)
|
||||
}
|
||||
|
||||
c.logger.Info("SSE connection established")
|
||||
|
||||
// 读取事件流
|
||||
return c.readEventStream(ctx, resp)
|
||||
}
|
||||
|
||||
// readEventStream 读取事件流
|
||||
func (c *SSEClient) readEventStream(ctx context.Context, resp *http.Response) error {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
|
||||
var eventType string
|
||||
var dataLines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.ctx.Done():
|
||||
return c.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
|
||||
// 空行表示事件结束
|
||||
if line == "" {
|
||||
if eventType != "" && len(dataLines) > 0 {
|
||||
c.processEvent(eventType, dataLines)
|
||||
eventType = ""
|
||||
dataLines = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 注释行(以 : 开头)
|
||||
if strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析字段
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
} else if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
dataLines = append(dataLines, data)
|
||||
}
|
||||
// 忽略其他字段(id, retry 等)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
|
||||
// processEvent 处理事件
|
||||
func (c *SSEClient) processEvent(eventType string, dataLines []string) {
|
||||
// 只处理 milky_event 类型
|
||||
if eventType != "milky_event" && eventType != "" {
|
||||
c.logger.Debug("Ignoring non-milky event", zap.String("event_type", eventType))
|
||||
return
|
||||
}
|
||||
|
||||
// 合并多行 data
|
||||
data := strings.Join(dataLines, "\n")
|
||||
|
||||
c.logger.Debug("Received SSE event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.Int("data_length", len(data)))
|
||||
|
||||
// 发送到事件通道
|
||||
select {
|
||||
case c.eventChan <- []byte(data):
|
||||
default:
|
||||
c.logger.Warn("Event channel full, dropping event")
|
||||
}
|
||||
}
|
||||
|
||||
// Events 获取事件通道
|
||||
func (c *SSEClient) Events() <-chan []byte {
|
||||
return c.eventChan
|
||||
}
|
||||
|
||||
// Close 关闭客户端
|
||||
func (c *SSEClient) Close() error {
|
||||
c.cancel()
|
||||
close(c.eventChan)
|
||||
c.logger.Info("SSE client closed")
|
||||
return nil
|
||||
}
|
||||
368
internal/adapter/milky/types.go
Normal file
368
internal/adapter/milky/types.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package milky
|
||||
|
||||
// Milky 协议类型定义
|
||||
// 基于官方 TypeScript 定义: https://github.com/SaltifyDev/milky
|
||||
|
||||
// ============================================================================
|
||||
// 标量类型
|
||||
// ============================================================================
|
||||
|
||||
// Int64 表示 64 位整数(QQ号、群号等)
|
||||
type Int64 = int64
|
||||
|
||||
// Int32 表示 32 位整数
|
||||
type Int32 = int32
|
||||
|
||||
// String 表示字符串
|
||||
type String = string
|
||||
|
||||
// Boolean 表示布尔值
|
||||
type Boolean = bool
|
||||
|
||||
// ============================================================================
|
||||
// 消息段类型
|
||||
// ============================================================================
|
||||
|
||||
// IncomingSegment 接收消息段(联合类型)
|
||||
type IncomingSegment struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// OutgoingSegment 发送消息段(联合类型)
|
||||
type OutgoingSegment struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// IncomingForwardedMessage 接收转发消息
|
||||
type IncomingForwardedMessage struct {
|
||||
SenderName string `json:"sender_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Time int64 `json:"time"`
|
||||
Segments []IncomingSegment `json:"segments"`
|
||||
}
|
||||
|
||||
// OutgoingForwardedMessage 发送转发消息
|
||||
type OutgoingForwardedMessage struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
Segments []OutgoingSegment `json:"segments"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 实体类型
|
||||
// ============================================================================
|
||||
|
||||
// FriendCategoryEntity 好友分组实体
|
||||
type FriendCategoryEntity struct {
|
||||
CategoryID int32 `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
}
|
||||
|
||||
// FriendEntity 好友实体
|
||||
type FriendEntity struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Sex string `json:"sex"` // male, female, unknown
|
||||
QID string `json:"qid"`
|
||||
Remark string `json:"remark"`
|
||||
Category FriendCategoryEntity `json:"category"`
|
||||
}
|
||||
|
||||
// GroupEntity 群实体
|
||||
type GroupEntity struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
MemberCount int32 `json:"member_count"`
|
||||
MaxMemberCount int32 `json:"max_member_count"`
|
||||
}
|
||||
|
||||
// GroupMemberEntity 群成员实体
|
||||
type GroupMemberEntity struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Sex string `json:"sex"` // male, female, unknown
|
||||
GroupID int64 `json:"group_id"`
|
||||
Card string `json:"card"`
|
||||
Title string `json:"title"`
|
||||
Level int32 `json:"level"`
|
||||
Role string `json:"role"` // owner, admin, member
|
||||
JoinTime int64 `json:"join_time"`
|
||||
LastSentTime int64 `json:"last_sent_time"`
|
||||
ShutUpEndTime *int64 `json:"shut_up_end_time,omitempty"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementEntity 群公告实体
|
||||
type GroupAnnouncementEntity struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
AnnouncementID string `json:"announcement_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Time int64 `json:"time"`
|
||||
Content string `json:"content"`
|
||||
ImageURL *string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// GroupFileEntity 群文件实体
|
||||
type GroupFileEntity struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
ParentFolderID string `json:"parent_folder_id"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
UploadedTime int64 `json:"uploaded_time"`
|
||||
ExpireTime *int64 `json:"expire_time,omitempty"`
|
||||
UploaderID int64 `json:"uploader_id"`
|
||||
DownloadedTimes int32 `json:"downloaded_times"`
|
||||
}
|
||||
|
||||
// GroupFolderEntity 群文件夹实体
|
||||
type GroupFolderEntity struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
FolderID string `json:"folder_id"`
|
||||
ParentFolderID string `json:"parent_folder_id"`
|
||||
FolderName string `json:"folder_name"`
|
||||
CreatedTime int64 `json:"created_time"`
|
||||
LastModifiedTime int64 `json:"last_modified_time"`
|
||||
CreatorID int64 `json:"creator_id"`
|
||||
FileCount int32 `json:"file_count"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息类型
|
||||
// ============================================================================
|
||||
|
||||
// IncomingMessage 接收消息(联合类型,使用 message_scene 区分)
|
||||
type IncomingMessage struct {
|
||||
MessageScene string `json:"message_scene"` // friend, group, temp
|
||||
PeerID int64 `json:"peer_id"`
|
||||
MessageSeq int64 `json:"message_seq"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
Time int64 `json:"time"`
|
||||
Segments []IncomingSegment `json:"segments"`
|
||||
|
||||
// 好友消息字段
|
||||
Friend *FriendEntity `json:"friend,omitempty"`
|
||||
|
||||
// 群消息字段
|
||||
Group *GroupEntity `json:"group,omitempty"`
|
||||
GroupMember *GroupMemberEntity `json:"group_member,omitempty"`
|
||||
}
|
||||
|
||||
// GroupEssenceMessage 群精华消息
|
||||
type GroupEssenceMessage struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
MessageSeq int64 `json:"message_seq"`
|
||||
MessageTime int64 `json:"message_time"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
OperationTime int64 `json:"operation_time"`
|
||||
Segments []IncomingSegment `json:"segments"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件数据类型
|
||||
// ============================================================================
|
||||
|
||||
// BotOfflineEventData 机器人离线事件数据
|
||||
type BotOfflineEventData struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// MessageRecallEventData 消息撤回事件数据
|
||||
type MessageRecallEventData struct {
|
||||
MessageScene string `json:"message_scene"` // friend, group, temp
|
||||
PeerID int64 `json:"peer_id"`
|
||||
MessageSeq int64 `json:"message_seq"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
DisplaySuffix string `json:"display_suffix"`
|
||||
}
|
||||
|
||||
// FriendRequestEventData 好友请求事件数据
|
||||
type FriendRequestEventData struct {
|
||||
InitiatorID int64 `json:"initiator_id"`
|
||||
InitiatorUID string `json:"initiator_uid"`
|
||||
Comment string `json:"comment"`
|
||||
Via string `json:"via"`
|
||||
}
|
||||
|
||||
// GroupJoinRequestEventData 入群申请事件数据
|
||||
type GroupJoinRequestEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
NotificationSeq int64 `json:"notification_seq"`
|
||||
IsFiltered bool `json:"is_filtered"`
|
||||
InitiatorID int64 `json:"initiator_id"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// GroupInvitedJoinRequestEventData 群成员邀请他人入群事件数据
|
||||
type GroupInvitedJoinRequestEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
NotificationSeq int64 `json:"notification_seq"`
|
||||
InitiatorID int64 `json:"initiator_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
}
|
||||
|
||||
// GroupInvitationEventData 他人邀请自身入群事件数据
|
||||
type GroupInvitationEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
InvitationSeq int64 `json:"invitation_seq"`
|
||||
InitiatorID int64 `json:"initiator_id"`
|
||||
}
|
||||
|
||||
// FriendNudgeEventData 好友戳一戳事件数据
|
||||
type FriendNudgeEventData struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
IsSelfSend bool `json:"is_self_send"`
|
||||
IsSelfReceive bool `json:"is_self_receive"`
|
||||
DisplayAction string `json:"display_action"`
|
||||
DisplaySuffix string `json:"display_suffix"`
|
||||
DisplayActionImgURL string `json:"display_action_img_url"`
|
||||
}
|
||||
|
||||
// FriendFileUploadEventData 好友文件上传事件数据
|
||||
type FriendFileUploadEventData struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileHash string `json:"file_hash"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
}
|
||||
|
||||
// GroupAdminChangeEventData 群管理员变更事件数据
|
||||
type GroupAdminChangeEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
// GroupEssenceMessageChangeEventData 群精华消息变更事件数据
|
||||
type GroupEssenceMessageChangeEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
MessageSeq int64 `json:"message_seq"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
// GroupMemberIncreaseEventData 群成员增加事件数据
|
||||
type GroupMemberIncreaseEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
OperatorID *int64 `json:"operator_id,omitempty"`
|
||||
InvitorID *int64 `json:"invitor_id,omitempty"`
|
||||
}
|
||||
|
||||
// GroupMemberDecreaseEventData 群成员减少事件数据
|
||||
type GroupMemberDecreaseEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
OperatorID *int64 `json:"operator_id,omitempty"`
|
||||
}
|
||||
|
||||
// GroupNameChangeEventData 群名称变更事件数据
|
||||
type GroupNameChangeEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
NewGroupName string `json:"new_group_name"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
}
|
||||
|
||||
// GroupMessageReactionEventData 群消息回应事件数据
|
||||
type GroupMessageReactionEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
MessageSeq int64 `json:"message_seq"`
|
||||
FaceID string `json:"face_id"`
|
||||
IsAdd bool `json:"is_add"`
|
||||
}
|
||||
|
||||
// GroupMuteEventData 群禁言事件数据
|
||||
type GroupMuteEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
Duration int32 `json:"duration"` // 秒,0表示取消禁言
|
||||
}
|
||||
|
||||
// GroupWholeMuteEventData 群全体禁言事件数据
|
||||
type GroupWholeMuteEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
IsMute bool `json:"is_mute"`
|
||||
}
|
||||
|
||||
// GroupNudgeEventData 群戳一戳事件数据
|
||||
type GroupNudgeEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
SenderID int64 `json:"sender_id"`
|
||||
ReceiverID int64 `json:"receiver_id"`
|
||||
DisplayAction string `json:"display_action"`
|
||||
DisplaySuffix string `json:"display_suffix"`
|
||||
DisplayActionImgURL string `json:"display_action_img_url"`
|
||||
}
|
||||
|
||||
// GroupFileUploadEventData 群文件上传事件数据
|
||||
type GroupFileUploadEventData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件类型
|
||||
// ============================================================================
|
||||
|
||||
// Event Milky 事件(联合类型,使用 event_type 区分)
|
||||
type Event struct {
|
||||
EventType string `json:"event_type"`
|
||||
Time int64 `json:"time"`
|
||||
SelfID int64 `json:"self_id"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// 事件类型常量
|
||||
const (
|
||||
EventTypeBotOffline = "bot_offline"
|
||||
EventTypeMessageReceive = "message_receive"
|
||||
EventTypeMessageRecall = "message_recall"
|
||||
EventTypeFriendRequest = "friend_request"
|
||||
EventTypeGroupJoinRequest = "group_join_request"
|
||||
EventTypeGroupInvitedJoinRequest = "group_invited_join_request"
|
||||
EventTypeGroupInvitation = "group_invitation"
|
||||
EventTypeFriendNudge = "friend_nudge"
|
||||
EventTypeFriendFileUpload = "friend_file_upload"
|
||||
EventTypeGroupAdminChange = "group_admin_change"
|
||||
EventTypeGroupEssenceMessageChange = "group_essence_message_change"
|
||||
EventTypeGroupMemberIncrease = "group_member_increase"
|
||||
EventTypeGroupMemberDecrease = "group_member_decrease"
|
||||
EventTypeGroupNameChange = "group_name_change"
|
||||
EventTypeGroupMessageReaction = "group_message_reaction"
|
||||
EventTypeGroupMute = "group_mute"
|
||||
EventTypeGroupWholeMute = "group_whole_mute"
|
||||
EventTypeGroupNudge = "group_nudge"
|
||||
EventTypeGroupFileUpload = "group_file_upload"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// API 响应类型
|
||||
// ============================================================================
|
||||
|
||||
// APIResponse API 响应
|
||||
type APIResponse struct {
|
||||
Status string `json:"status"` // ok, failed
|
||||
RetCode int `json:"retcode"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// 响应状态码
|
||||
const (
|
||||
RetCodeSuccess = 0
|
||||
RetCodeNotLoggedIn = -403
|
||||
RetCodeInvalidParams = -400
|
||||
RetCodeNotFound = -404
|
||||
)
|
||||
115
internal/adapter/milky/webhook_server.go
Normal file
115
internal/adapter/milky/webhook_server.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package milky
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebhookServer Webhook 服务器
|
||||
// 用于接收协议端 POST 推送的事件
|
||||
type WebhookServer struct {
|
||||
server *fasthttp.Server
|
||||
eventChan chan []byte
|
||||
logger *zap.Logger
|
||||
addr string
|
||||
}
|
||||
|
||||
// NewWebhookServer 创建 Webhook 服务器
|
||||
func NewWebhookServer(addr string, logger *zap.Logger) *WebhookServer {
|
||||
return &WebhookServer{
|
||||
eventChan: make(chan []byte, 100),
|
||||
logger: logger.Named("webhook-server"),
|
||||
addr: addr,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动服务器
|
||||
func (s *WebhookServer) Start() error {
|
||||
s.server = &fasthttp.Server{
|
||||
Handler: s.handleRequest,
|
||||
MaxConnsPerIP: 1000,
|
||||
MaxRequestsPerConn: 1000,
|
||||
}
|
||||
|
||||
s.logger.Info("Starting webhook server", zap.String("addr", s.addr))
|
||||
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(s.addr); err != nil {
|
||||
s.logger.Error("Webhook server error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRequest 处理请求
|
||||
func (s *WebhookServer) handleRequest(ctx *fasthttp.RequestCtx) {
|
||||
// 只接受 POST 请求
|
||||
if !ctx.IsPost() {
|
||||
s.logger.Warn("Received non-POST request",
|
||||
zap.String("method", string(ctx.Method())))
|
||||
ctx.Error("Method Not Allowed", fasthttp.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Content-Type
|
||||
contentType := string(ctx.Request.Header.ContentType())
|
||||
if contentType != "application/json" {
|
||||
s.logger.Warn("Invalid content type",
|
||||
zap.String("content_type", contentType))
|
||||
ctx.Error("Unsupported Media Type", fasthttp.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取请求体
|
||||
body := ctx.PostBody()
|
||||
if len(body) == 0 {
|
||||
s.logger.Warn("Empty request body")
|
||||
ctx.Error("Bad Request", fasthttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
var event Event
|
||||
if err := sonic.Unmarshal(body, &event); err != nil {
|
||||
s.logger.Error("Failed to parse event", zap.Error(err))
|
||||
ctx.Error("Bad Request", fasthttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("Received webhook event",
|
||||
zap.String("event_type", event.EventType),
|
||||
zap.Int64("self_id", event.SelfID))
|
||||
|
||||
// 发送到事件通道
|
||||
select {
|
||||
case s.eventChan <- body:
|
||||
default:
|
||||
s.logger.Warn("Event channel full, dropping event")
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
ctx.SetContentType("application/json")
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
ctx.SetBodyString(`{"status":"ok"}`)
|
||||
}
|
||||
|
||||
// Events 获取事件通道
|
||||
func (s *WebhookServer) Events() <-chan []byte {
|
||||
return s.eventChan
|
||||
}
|
||||
|
||||
// Stop 停止服务器
|
||||
func (s *WebhookServer) Stop() error {
|
||||
if s.server != nil {
|
||||
s.logger.Info("Stopping webhook server")
|
||||
if err := s.server.Shutdown(); err != nil {
|
||||
return fmt.Errorf("failed to shutdown webhook server: %w", err)
|
||||
}
|
||||
}
|
||||
close(s.eventChan)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user