mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 18:15:47 +00:00
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:
1 parent
124d4bee0d
commit
7e51666357
4 files changed
+79
-10
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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":{}}`)
|
||||
|
||||
|
||||
Reference in new issue
Block a user