diff --git a/docs/robot.md b/docs/robot.md index 2528067d..eb4c814e 100644 --- a/docs/robot.md +++ b/docs/robot.md @@ -189,6 +189,9 @@ curl -X POST "http://localhost:8080/api/robot/test" \ 按顺序检查: +0. **笔记本合盖睡眠 / 断网后** + 钉钉、飞书均使用长连接收消息,睡眠或断网后连接会断开。程序会**自动重连**(约 5 秒~60 秒内重试)。唤醒或恢复网络后稍等一会儿再发消息;若仍无反应,可重启 CyberStrikeAI 进程。 + 1. **Client ID / Client Secret 是否与开放平台完全一致** 从「凭证与基础信息」里**复制粘贴**,不要手打。注意数字 **0** 与字母 **o**、数字 **1** 与字母 **l**(例如 `ding9gf9tiozuc504aer` 中间是 **504** 不是 5o4)。 diff --git a/docs/robot_en.md b/docs/robot_en.md index 8d214964..5491529a 100644 --- a/docs/robot_en.md +++ b/docs/robot_en.md @@ -188,6 +188,9 @@ API: `POST /api/robot/test` (requires login). Body: `{"platform":"optional","use Check in this order: +0. **After laptop sleep or network drop** + DingTalk and Lark both use long-lived connections; they break when the machine sleeps or the network drops. The app **auto-reconnects** (retries within about 5–60 seconds). After wake or network recovery, wait a moment before sending; if there is still no response, restart the CyberStrikeAI process. + 1. **Client ID / Client Secret match the open platform exactly** Copy from “Credentials and basic info”; avoid typing. Watch **0** vs **o** and **1** vs **l** (e.g. `ding9gf9tiozuc504aer` has **504**, not 5o4). diff --git a/internal/robot/ding.go b/internal/robot/ding.go index 29adca8c..b391fe0c 100644 --- a/internal/robot/ding.go +++ b/internal/robot/ding.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "strings" + "time" "cyberstrike-ai/internal/config" @@ -15,30 +16,54 @@ import ( "go.uber.org/zap" ) +const ( + dingReconnectInitial = 5 * time.Second // 首次重连间隔 + dingReconnectMax = 60 * time.Second // 最大重连间隔 +) + // StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。 -// ctx 被取消时长连接会退出,便于配置变更时重启。 +// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { return } - streamClient := client.NewStreamClient( - client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), - client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", - chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { - go handleDingMessage(ctx, msg, h, logger) - return nil, nil - }).OnEventReceived), - ) - logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID)) - go func() { + go runDingLoop(ctx, cfg, h, logger) +} + +// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。 +func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { + backoff := dingReconnectInitial + for { + streamClient := client.NewStreamClient( + client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), + client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", + chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { + go handleDingMessage(ctx, msg, h, logger) + return nil, nil + }).OnEventReceived), + ) + logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID)) err := streamClient.Start(ctx) - if err != nil && ctx.Err() == nil { - logger.Error("钉钉 Stream 长连接退出", zap.Error(err)) - } else if ctx.Err() != nil { + if ctx.Err() != nil { logger.Info("钉钉 Stream 已按配置重启关闭") + return } - }() - logger.Info("钉钉 Stream 已启动(无需公网),等待收消息", zap.String("client_id", cfg.ClientID)) + if err != nil { + logger.Warn("钉钉 Stream 长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + // 下次重连间隔递增,上限 60 秒,避免频繁重试 + if backoff < dingReconnectMax { + backoff *= 2 + if backoff > dingReconnectMax { + backoff = dingReconnectMax + } + } + } + } } func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) { diff --git a/internal/robot/lark.go b/internal/robot/lark.go index a57bc7b6..9e70af0a 100644 --- a/internal/robot/lark.go +++ b/internal/robot/lark.go @@ -4,45 +4,70 @@ import ( "context" "encoding/json" "strings" + "time" "cyberstrike-ai/internal/config" + lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - lark "github.com/larksuite/oapi-sdk-go/v3" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "go.uber.org/zap" ) +const ( + larkReconnectInitial = 5 * time.Second // 首次重连间隔 + larkReconnectMax = 60 * time.Second // 最大重连间隔 +) + type larkTextContent struct { Text string `json:"text"` } // StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。 -// ctx 被取消时长连接会退出,便于配置变更时重启。 +// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { return } - larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) - eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { - go handleLarkMessage(ctx, event, h, larkClient, logger) - return nil - }) - wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, - larkws.WithEventHandler(eventHandler), - larkws.WithLogLevel(larkcore.LogLevelInfo), - ) - go func() { + go runLarkLoop(ctx, cfg, h, logger) +} + +// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。 +func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { + backoff := larkReconnectInitial + for { + larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) + eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { + go handleLarkMessage(ctx, event, h, larkClient, logger) + return nil + }) + wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, + larkws.WithEventHandler(eventHandler), + larkws.WithLogLevel(larkcore.LogLevelInfo), + ) + logger.Info("飞书长连接正在连接…", zap.String("app_id", cfg.AppID)) err := wsClient.Start(ctx) - if err != nil && ctx.Err() == nil { - logger.Error("飞书长连接退出", zap.Error(err)) - } else if ctx.Err() != nil { + if ctx.Err() != nil { logger.Info("飞书长连接已按配置重启关闭") + return } - }() - logger.Info("飞书长连接已启动(无需公网)", zap.String("app_id", cfg.AppID)) + if err != nil { + logger.Warn("飞书长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + if backoff < larkReconnectMax { + backoff *= 2 + if backoff > larkReconnectMax { + backoff = larkReconnectMax + } + } + } + } } func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) {