chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d)
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendText(t *testing.T) {
|
||||
var gotText string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open-apis/auth/v3/tenant_access_token/internal":
|
||||
_, _ = w.Write([]byte(`{"code":0,"tenant_access_token":"test-token","expire":7200}`))
|
||||
case "/open-apis/im/v1/messages":
|
||||
if r.URL.Query().Get("receive_id_type") != "chat_id" || r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Errorf("unexpected request: %s %s", r.URL.String(), r.Header.Get("Authorization"))
|
||||
}
|
||||
var payload struct {
|
||||
ReceiveID string `json:"receive_id"`
|
||||
MsgType string `json:"msg_type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if payload.ReceiveID != "test-chat" || payload.MsgType != "text" {
|
||||
t.Errorf("unexpected payload: %+v", payload)
|
||||
}
|
||||
var content struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload.Content), &content); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
gotText = content.Text
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"success"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NewClient("test-app", "test-secret", "test-chat")
|
||||
client.baseURL = server.URL
|
||||
message := "连接测试:含引号 \" 和换行\n第二行"
|
||||
if err := client.SendText(context.Background(), message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotText != message {
|
||||
t.Fatalf("text mismatch: %q", gotText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTextRejectsEmpty(t *testing.T) {
|
||||
client := NewClient("test-app", "test-secret", "test-chat")
|
||||
if err := client.SendText(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "text is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user