chore: 初始开源发布

iotlcm-bot:独立飞书应用机器人(读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片)。

- Go module:git.5iot.com/iotlcm/iotlcm-bot → git.5iot.com/iotlcm-public/iotlcm-bot
- 飞书凭据占位:appId/appSecret/chatId → ${FEISHU_APP_ID}/${FEISHU_APP_SECRET}/${FEISHU_CHAT_ID}
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → scratch)
- README 重写(命令/配置/挂载契约/常见问题),去掉内部环境路径
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes
This commit is contained in:
MiFaZhan
2026-10-22 16:14:24 +08:00
commit 5732bf3384
33 changed files with 2676 additions and 0 deletions
+155
View File
@@ -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
}