chore: update dependencies and refactor webhook handling

- Added new dependencies for SQLite support and improved HTTP client functionality in go.mod and go.sum.
- Refactored webhook server implementation to utilize a simplified version, enhancing code maintainability.
- Updated API client to leverage a generic request method, streamlining API interactions.
- Modified configuration to include access token for webhook server, improving security.
- Enhanced event handling and request processing in the API client for better performance.
This commit is contained in:
2026-01-05 18:42:45 +08:00
parent fb5fae1524
commit f3a72264af
10 changed files with 346 additions and 181 deletions

View File

@@ -185,7 +185,7 @@ func (a *Adapter) startWebhook() error {
return fmt.Errorf("webhook_listen_addr is required for webhook mode")
}
a.webhookServer = NewWebhookServer(a.config.WebhookListenAddr, a.logger)
a.webhookServer = NewWebhookServer(a.config.WebhookListenAddr, a.config.AccessToken, a.logger)
// 启动服务器
if err := a.webhookServer.Start(); err != nil {

View File

@@ -5,20 +5,19 @@ import (
"fmt"
"time"
"cellbot/pkg/net"
"github.com/bytedance/sonic"
"github.com/valyala/fasthttp"
"go.uber.org/zap"
)
// APIClient Milky API 客户端
// 用于调用协议端的 API (POST /api/:api)
// 使用 pkg/net.HTTPClient 提供的通用 HTTP 请求功能
type APIClient struct {
httpClient *net.HTTPClient
baseURL string
accessToken string
httpClient *fasthttp.Client
logger *zap.Logger
timeout time.Duration
retryCount int
}
// NewAPIClient 创建 API 客户端
@@ -30,17 +29,17 @@ func NewAPIClient(baseURL, accessToken string, timeout time.Duration, retryCount
retryCount = 3
}
config := net.HTTPClientConfig{
BaseURL: baseURL,
Timeout: timeout,
RetryCount: retryCount,
}
return &APIClient{
httpClient: net.NewHTTPClient(config, logger.Named("api-client"), nil),
baseURL: baseURL,
accessToken: accessToken,
httpClient: &fasthttp.Client{
ReadTimeout: timeout,
WriteTimeout: timeout,
MaxConnsPerHost: 100,
},
logger: logger.Named("api-client"),
timeout: timeout,
retryCount: retryCount,
logger: logger.Named("api-client"),
}
}
@@ -69,63 +68,26 @@ func (c *APIClient) Call(ctx context.Context, endpoint string, input interface{}
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)
return c.doRequest(ctx, url, inputData)
}
// 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))
// 使用 pkg/net.HTTPClient 的通用请求方法
config := net.GenericRequestConfig{
URL: url,
Method: "POST",
Body: inputData,
AccessToken: c.accessToken,
}
// 发送请求
err := c.httpClient.DoTimeout(req, resp, c.timeout)
resp, err := c.httpClient.DoGenericRequest(ctx, config)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// 检查 HTTP 状态码
statusCode := resp.StatusCode()
switch statusCode {
switch resp.StatusCode {
case 401:
return nil, fmt.Errorf("unauthorized: access token invalid or missing")
case 404:
@@ -135,12 +97,12 @@ func (c *APIClient) doRequest(ctx context.Context, url string, inputData []byte)
case 200:
// 继续处理
default:
return nil, fmt.Errorf("unexpected status code: %d", statusCode)
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// 解析响应
var apiResp APIResponse
if err := sonic.Unmarshal(resp.Body(), &apiResp); err != nil {
if err := sonic.Unmarshal(resp.Body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
@@ -162,28 +124,14 @@ func (c *APIClient) doRequest(ctx context.Context, url string, inputData []byte)
}
// CallWithoutRetry 调用 API不重试
// 注意pkg/net.HTTPClient 的通用请求已经内置了重试机制
// 这个方法保留是为了向后兼容,但仍然会使用底层的重试机制
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)
return c.Call(ctx, endpoint, input)
}
// Close 关闭客户端
func (c *APIClient) Close() error {
// fasthttp.Client 不需要显式关闭
// pkg/net.HTTPClient 的 fasthttp.Client 不需要显式关闭
return nil
}

View File

@@ -1,115 +1,60 @@
package milky
import (
"cellbot/pkg/net"
"fmt"
"github.com/bytedance/sonic"
"github.com/valyala/fasthttp"
"go.uber.org/zap"
)
// WebhookServer Webhook 服务器
// 用于接收协议端 POST 推送的事件
// 使用 pkg/net.SimpleWebhookServer 实现,避免重复的 fasthttp 代码
type WebhookServer struct {
server *fasthttp.Server
eventChan chan []byte
logger *zap.Logger
addr string
server *net.SimpleWebhookServer
logger *zap.Logger
addr string
}
// NewWebhookServer 创建 Webhook 服务器
func NewWebhookServer(addr string, logger *zap.Logger) *WebhookServer {
func NewWebhookServer(addr string, accessToken string, logger *zap.Logger) *WebhookServer {
eventChan := make(chan []byte, 100)
config := net.SimpleWebhookConfig{
Addr: addr,
AccessToken: accessToken,
EventChannel: eventChan,
}
return &WebhookServer{
eventChan: make(chan []byte, 100),
logger: logger.Named("webhook-server"),
addr: addr,
server: net.NewSimpleWebhookServer(config, logger),
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))
}
}()
if err := s.server.Start(); err != nil {
return fmt.Errorf("failed to start webhook server: %w", 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
return s.server.Events()
}
// 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)
}
s.logger.Info("Stopping webhook server")
if err := s.server.Stop(); err != nil {
return fmt.Errorf("failed to stop webhook server: %w", err)
}
close(s.eventChan)
return nil
}

View File

@@ -7,7 +7,7 @@ import (
"sync"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)

View File

@@ -61,13 +61,11 @@ func RegisterLifecycleHooks(
logger.Error("Failed to stop bots", zap.Error(err))
}
// 停止分发器
// 停止分发器(先停止分发器,让它有机会处理完当前事件)
dispatcher.Stop()
// 停止事件总线
eventBus.Stop()
logger.Info("CellBot application stopped successfully")
return nil
},
})

View File

@@ -136,14 +136,19 @@ func handleMCSCommand(ctx context.Context, event protocol.Event, botManager *pro
Logger: logger,
}
// 使用独立的 context 进行截图,避免受 dispatcher context 影响
// 如果 dispatcher context 被取消,截图操作仍能完成
screenshotCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
// 使用独立的 context 进行截图,完全避免受外部 context 影响
// 使用更长的超时时间,避免频繁失败
screenshotCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
// 渲染并截图
chain, err := utils.ScreenshotHTMLToMessageChain(screenshotCtx, htmlTemplate, opts)
if err != nil {
// context.Canceled 是应用关闭时的正常行为,不记录为错误
if err.Error() == "screenshot operation was canceled" {
logger.Warn("Screenshot canceled due to application shutdown")
return nil
}
logger.Error("Failed to render status image", zap.Error(err))
return err
}