Files
jason1804 7e51666357 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
2026-06-21 03:34:07 +07:00

256 lines
6.5 KiB
Go

package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"sync"
"time"
)
const (
tokenExpiryBuffer = 3 * time.Minute
tokenEndpoint = "/open-apis/auth/v3/tenant_access_token/internal"
)
// LarkClient is a lightweight Feishu/Lark API client using net/http.
// Handles tenant_access_token auto-refresh and all REST API calls.
type LarkClient struct {
baseURL string
appID string
appSecret string
httpClient *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// NewLarkClient creates a native Lark HTTP client.
func NewLarkClient(appID, appSecret, baseURL string) *LarkClient {
return &LarkClient{
baseURL: baseURL,
appID: appID,
appSecret: appSecret,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// --- Token management ---
func (c *LarkClient) getToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body, _ := json.Marshal(map[string]string{
"app_id": c.appID,
"app_secret": c.appSecret,
})
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+tokenEndpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("lark token request: %w", err)
}
defer resp.Body.Close()
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("lark token decode: %w", err)
}
if result.Code != 0 {
return "", fmt.Errorf("lark token error: code=%d msg=%s", result.Code, result.Msg)
}
c.token = result.TenantAccessToken
c.tokenExp = time.Now().Add(time.Duration(result.Expire)*time.Second - tokenExpiryBuffer)
return c.token, nil
}
func (c *LarkClient) clearToken() {
c.mu.Lock()
c.token = ""
c.tokenExp = time.Time{}
c.mu.Unlock()
}
// isTokenError returns true if the error code indicates an expired/invalid token.
func isTokenError(code int) bool {
return code == 99991663 || code == 99991664 || code == 99991671
}
// --- Generic API helpers ---
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.
func (c *LarkClient) doJSON(ctx context.Context, method, path string, body any) (*apiResponse, error) {
resp, err := c.doJSONOnce(ctx, method, path, body)
if err != nil {
return nil, err
}
// Retry once on token error
if isTokenError(resp.Code) {
c.clearToken()
return c.doJSONOnce(ctx, method, path, body)
}
return resp, nil
}
func (c *LarkClient) doJSONOnce(ctx context.Context, method, path string, body any) (*apiResponse, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("lark api %s %s: %w", method, path, err)
}
defer resp.Body.Close()
var result apiResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("lark api decode: %w", err)
}
return &result, nil
}
// doDownload performs an authenticated GET that returns raw bytes.
func (c *LarkClient) doDownload(ctx context.Context, path string) ([]byte, string, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, "", err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("lark download %s: %w", path, err)
}
defer resp.Body.Close()
// Check for JSON error response
ct := resp.Header.Get("Content-Type")
if ct != "" {
mt, _, _ := mime.ParseMediaType(ct)
if mt == "application/json" {
var errResp apiResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil && errResp.Code != 0 {
return nil, "", fmt.Errorf("lark download error: code=%d msg=%s", errResp.Code, errResp.Msg)
}
}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("lark read download: %w", err)
}
// Extract filename from Content-Disposition
fileName := ""
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
_, params, _ := mime.ParseMediaType(cd)
fileName = params["filename"]
}
return data, fileName, nil
}
// doMultipart performs an authenticated multipart upload.
func (c *LarkClient) doMultipart(ctx context.Context, path string, fields map[string]string, fileField string, fileData io.Reader, fileName string) (*apiResponse, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for k, v := range fields {
writer.WriteField(k, v)
}
if fileField != "" && fileData != nil {
if fileName == "" {
fileName = "upload"
}
part, err := writer.CreateFormFile(fileField, fileName)
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(part, fileData); err != nil {
return nil, fmt.Errorf("copy file data: %w", err)
}
}
writer.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("lark upload %s: %w", path, err)
}
defer resp.Body.Close()
var result apiResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("lark upload decode: %w", err)
}
return &result, nil
}