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
}
+62
View File
@@ -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)
}
}