package feishu import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "sync" "time" ) type Client struct { appID string appSecret string chatID string http *http.Client baseURL string mu sync.Mutex token string tokenExp time.Time } func NewClient(appID, appSecret, chatID string) *Client { return &Client{appID: appID, appSecret: appSecret, chatID: chatID, http: &http.Client{Timeout: 10 * time.Second}, baseURL: "https://open.feishu.cn"} } func (c *Client) SendText(ctx context.Context, text string) error { if c.chatID == "" { return fmt.Errorf("feishu chatId is required") } if text == "" { return fmt.Errorf("text is required") } token, err := c.accessToken(ctx, false) if err != nil { return err } if err := c.sendTextWithToken(ctx, token, text); err != nil && err.Error() == "feishu authentication failed" { token, err = c.accessToken(ctx, true) if err != nil { return err } return c.sendTextWithToken(ctx, token, text) } return err } func (c *Client) Send(ctx context.Context, card []byte) error { if c.chatID == "" { return fmt.Errorf("feishu chatId is required") } token, err := c.accessToken(ctx, false) if err != nil { return err } if err := c.sendWithToken(ctx, token, card); err != nil && err.Error() == "feishu authentication failed" { token, err = c.accessToken(ctx, true) if err != nil { return err } return c.sendWithToken(ctx, token, card) } else { return err } } func (c *Client) accessToken(ctx context.Context, force bool) (string, error) { c.mu.Lock() defer c.mu.Unlock() if !force && c.token != "" && time.Until(c.tokenExp) > time.Minute { return c.token, nil } body, _ := json.Marshal(map[string]string{"app_id": c.appID, "app_secret": c.appSecret}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { return "", 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 "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 || result.TenantAccessToken == "" { return "", fmt.Errorf("feishu token request failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg) } c.token, c.tokenExp = result.TenantAccessToken, time.Now().Add(time.Duration(result.Expire)*time.Second) return c.token, nil } func (c *Client) sendTextWithToken(ctx context.Context, token, text string) error { payload, _ := json.Marshal(map[string]string{"receive_id": c.chatID, "msg_type": "text", "content": fmt.Sprintf(`{"text":%q}`, text)}) return c.postMessage(ctx, token, payload) } func (c *Client) sendWithToken(ctx context.Context, token string, card []byte) error { var content json.RawMessage if err := json.Unmarshal(card, &content); err != nil { return fmt.Errorf("invalid card: %w", err) } var outer map[string]any if err := json.Unmarshal(content, &outer); err != nil { return err } msgType, ok := outer["msg_type"].(string) if !ok { return fmt.Errorf("card msg_type is required") } cardBody, ok := outer["card"] if !ok { return fmt.Errorf("card body is required") } cardJSON, _ := json.Marshal(cardBody) payload, _ := json.Marshal(map[string]any{"receive_id": c.chatID, "msg_type": msgType, "content": string(cardJSON)}) return c.postMessage(ctx, token, payload) } func (c *Client) postMessage(ctx context.Context, token string, payload []byte) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/open-apis/im/v1/messages?receive_id_type="+url.QueryEscape("chat_id"), bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) var result struct { Code int `json:"code"` Msg string `json:"msg"` } _ = json.Unmarshal(data, &result) if resp.StatusCode == http.StatusUnauthorized { return fmt.Errorf("feishu authentication failed") } if resp.StatusCode < 200 || resp.StatusCode >= 300 || result.Code != 0 { return fmt.Errorf("feishu send failed: http=%d code=%d msg=%s", resp.StatusCode, result.Code, result.Msg) } return nil }