From 41a0eac642f9d36bff542f0e0f76c437af2525e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=9F=A0?= <865274218@qq.com> Date: Tue, 10 Mar 2026 19:43:00 +0800 Subject: [PATCH] fix(feishu): invalidate cached token on auth error to enable retry recovery The Lark SDK v3 has a bug where its built-in token retry loop does not clear stale tokens from cache when the server returns error 99991663 (tenant_access_token invalid). This causes all API calls to fail until the token naturally expires (~2 hours). Add a custom tokenCache (implementing larkcore.Cache) with an InvalidateAll() method. On any API response with code 99991663, clear the cache so the next application-level retry fetches a fresh token. --- pkg/channels/feishu/feishu_64.go | 79 ++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index ffe03c915..18735e54e 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -13,6 +13,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -29,11 +30,17 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked +// tenant_access_token. The Lark SDK's built-in retry does not clear its cache +// on this error, so we do it ourselves. +const errCodeTenantTokenInvalid = 99991663 + type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig - client *lark.Client - wsClient *larkws.Client + config config.FeishuConfig + client *lark.Client + wsClient *larkws.Client + tokenCache *tokenCache // custom cache that supports invalidation botOpenID atomic.Value // stores string; populated lazily for @mention detection @@ -47,10 +54,12 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + tc := newTokenCache() ch := &FeishuChannel{ BaseChannel: base, config: cfg, - client: lark.NewClient(cfg.AppID, cfg.AppSecret), + tokenCache: tc, + client: lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithTokenCache(tc)), } ch.SetOwner(ch) return ch, nil @@ -147,6 +156,7 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return fmt.Errorf("feishu edit: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -186,6 +196,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("feishu placeholder send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) } @@ -226,6 +237,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st return func() {}, fmt.Errorf("feishu react: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Reaction API error", map[string]any{ "emoji": chosenEmoji, "message_id": messageID, @@ -470,6 +482,7 @@ func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error { return fmt.Errorf("bot info parse: %w", err) } if result.Code != 0 { + c.invalidateTokenOnAuthError(result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code) } if result.Bot.OpenID == "" { @@ -612,6 +625,7 @@ func (c *FeishuChannel) downloadResource( return "" } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Resource download api error", map[string]any{ "code": resp.Code, "msg": resp.Msg, @@ -724,6 +738,7 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } @@ -749,6 +764,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { @@ -773,6 +789,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -803,6 +820,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { @@ -827,6 +845,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -849,3 +868,55 @@ func extractFeishuSenderID(sender *larkim.EventSender) string { return "" } + +// invalidateTokenOnAuthError clears the cached tenant_access_token when the +// Feishu API reports it as invalid (99991663), so the next request fetches a +// fresh one. The Lark SDK's built-in retry does not clear the cache, causing +// all API calls to fail until the token naturally expires (~2 hours). +func (c *FeishuChannel) invalidateTokenOnAuthError(code int) { + if code == errCodeTenantTokenInvalid { + c.tokenCache.InvalidateAll() + logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil) + } +} + +// tokenCache implements larkcore.Cache with an extra InvalidateAll method. +// This works around a bug in the Lark SDK v3 where the built-in token retry +// loop does not clear stale tokens from cache on auth errors. +type tokenCache struct { + mu sync.RWMutex + store map[string]*tokenEntry +} + +type tokenEntry struct { + value string + expireAt time.Time +} + +func newTokenCache() *tokenCache { + return &tokenCache{store: make(map[string]*tokenEntry)} +} + +func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)} + return nil +} + +func (c *tokenCache) Get(_ context.Context, key string) (string, error) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.store[key] + if !ok || e.expireAt.Before(time.Now()) { + return "", nil + } + return e.value, nil +} + +// InvalidateAll removes all cached tokens, forcing fresh acquisition. +func (c *tokenCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + clear(c.store) +}