diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index 9c7d7072..78289d1b 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -135,8 +135,10 @@ func (c *Channel) Start(ctx context.Context) error { c.GroupHistory().StartFlusher() slog.Info("starting feishu/lark bot") - // Probe bot identity - if err := c.probeBotInfo(ctx); err != nil { + // Probe bot identity. Without botOpenID, mention detection degrades to + // "any mention counts as bot mention" (see parseMessageEvent), so retry + // transient failures before giving up. + if err := c.probeBotInfoWithRetry(ctx, 3); err != nil { slog.Warn("feishu bot probe failed (will continue)", "error", err) } else { slog.Info("feishu bot connected", "bot_open_id", c.botOpenID) @@ -370,6 +372,25 @@ func (c *Channel) probeBotInfo(ctx context.Context) error { return nil } +// probeBotInfoWithRetry retries probeBotInfo with linear backoff to survive +// transient startup failures (network blips, token service warm-up). +func (c *Channel) probeBotInfoWithRetry(ctx context.Context, attempts int) error { + var err error + for i := 0; i < attempts; i++ { + if err = c.probeBotInfo(ctx); err == nil { + return nil + } + if i < attempts-1 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(i+1) * 2 * time.Second): + } + } + } + return err +} + // --- Send helpers --- func (c *Channel) sendChunkedText(ctx context.Context, chatID, receiveIDType, text string, chunkLimit int, replyTargetID string) error { diff --git a/internal/channels/feishu/larkclient.go b/internal/channels/feishu/larkclient.go index 0e58bfb3..e256c9e2 100644 --- a/internal/channels/feishu/larkclient.go +++ b/internal/channels/feishu/larkclient.go @@ -104,6 +104,9 @@ type apiResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data json.RawMessage `json:"data"` + // Bot is only present on the legacy /open-apis/bot/v3/info endpoint, + // which returns the bot object at the top level instead of inside "data". + Bot json.RawMessage `json:"bot"` } // doJSON performs an authenticated JSON API call with auto token refresh. diff --git a/internal/channels/feishu/larkclient_messaging.go b/internal/channels/feishu/larkclient_messaging.go index 06da51c0..773d6412 100644 --- a/internal/channels/feishu/larkclient_messaging.go +++ b/internal/channels/feishu/larkclient_messaging.go @@ -270,6 +270,9 @@ func (c *LarkClient) DeleteMessageReaction(ctx context.Context, messageID, react // GetBotInfo fetches the bot's identity from /open-apis/bot/v3/info. // Returns the bot's open_id which is needed for mention detection in groups. +// This legacy endpoint returns the bot object at the TOP level of the +// response ({"code":0,"msg":"ok","bot":{...}}), not inside "data" like +// newer Lark APIs — read resp.Bot first, with a data.bot fallback. func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { resp, err := c.doJSON(ctx, "GET", "/open-apis/bot/v3/info", nil) if err != nil { @@ -278,15 +281,31 @@ func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) { if resp.Code != 0 { return "", fmt.Errorf("get bot info: code=%d msg=%s", resp.Code, resp.Msg) } - var result struct { - Bot struct { - OpenID string `json:"open_id"` - } `json:"bot"` + + var botInfo struct { + OpenID string `json:"open_id"` } - if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("unmarshal response: %w", err) + if len(resp.Bot) > 0 { + if err := json.Unmarshal(resp.Bot, &botInfo); err != nil { + return "", fmt.Errorf("unmarshal bot info: %w", err) + } + return botInfo.OpenID, nil } - return result.Bot.OpenID, nil + + // Fallback: some deployments may wrap the bot object inside "data". + if len(resp.Data) > 0 { + var wrapped struct { + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + if err := json.Unmarshal(resp.Data, &wrapped); err != nil { + return "", fmt.Errorf("unmarshal bot info data: %w", err) + } + return wrapped.Bot.OpenID, nil + } + + return "", fmt.Errorf("get bot info: response missing bot object") } // --- IM API: Chat Members --- diff --git a/internal/channels/feishu/larkclient_messaging_extra_test.go b/internal/channels/feishu/larkclient_messaging_extra_test.go index 2e2771a6..fa03187b 100644 --- a/internal/channels/feishu/larkclient_messaging_extra_test.go +++ b/internal/channels/feishu/larkclient_messaging_extra_test.go @@ -273,7 +273,9 @@ func TestDeleteMessageReaction_APIError(t *testing.T) { // --- GetBotInfo (returns open_id string) --- func TestGetBotInfo_Success(t *testing.T) { - srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_123","app_name":"TestBot"}}}`) + // Real /open-apis/bot/v3/info responses put "bot" at the TOP level, + // not inside "data" (legacy API shape). + srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","bot":{"open_id":"ou_bot_123","app_name":"TestBot"}}`) c := NewLarkClient("app", "secret", srv.URL) openID, err := c.GetBotInfo(context.Background()) @@ -285,6 +287,30 @@ func TestGetBotInfo_Success(t *testing.T) { } } +func TestGetBotInfo_DataWrappedFallback(t *testing.T) { + // Defensive fallback for deployments that wrap the bot object in "data". + srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_456","app_name":"TestBot"}}}`) + + c := NewLarkClient("app", "secret", srv.URL) + openID, err := c.GetBotInfo(context.Background()) + if err != nil { + t.Fatalf("GetBotInfo error: %v", err) + } + if openID != "ou_bot_456" { + t.Errorf("open_id: got %q, want ou_bot_456", openID) + } +} + +func TestGetBotInfo_MissingBotObject(t *testing.T) { + srv := newSimpleMockServer(t, `{"code":0,"msg":"ok"}`) + + c := NewLarkClient("app", "secret", srv.URL) + _, err := c.GetBotInfo(context.Background()) + if err == nil { + t.Fatal("expected error when response has no bot object") + } +} + func TestGetBotInfo_APIError(t *testing.T) { srv := newSimpleMockServer(t, `{"code":10001,"msg":"forbidden","data":{}}`)