fix(feishu): parse bot/v3/info top-level bot object for mention detection (#1189)

The legacy /open-apis/bot/v3/info endpoint returns the bot object at the
top level of the response, not inside "data" like newer Lark APIs. The
probe always failed to extract open_id, leaving botOpenID empty, which
made every group mention count as a bot mention — the bot replied to
messages mentioning other users even with requireMention enabled.

- Read top-level "bot" first, fall back to data.bot defensively
- Retry the startup probe with backoff to survive transient failures
- Fix test fixtures to match the real API response shape

Fixes #895
This commit is contained in:
jason1804 authored and GitHub committed 2026-06-21 03:34:07 +07:00
1 parent 124d4bee0d
commit 7e51666357
4 files changed
+79 -10

No files matched your search

+23 -2
View File
@@ -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 {
+3
View File
@@ -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.
@@ -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 ---
@@ -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":{}}`)