443 lines
14 KiB
Go
443 lines
14 KiB
Go
package feishu
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var openAPIBaseURL = "https://open.feishu.cn/open-apis"
|
|
|
|
type OpenAPIClient struct {
|
|
BaseURL string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
type TenantToken struct {
|
|
Value string
|
|
Expire int
|
|
}
|
|
|
|
type WikiNode struct {
|
|
SpaceID string `json:"space_id"`
|
|
NodeToken string `json:"node_token"`
|
|
ObjToken string `json:"obj_token"`
|
|
ObjType string `json:"obj_type"`
|
|
NodeType string `json:"node_type"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type CreatedDocument struct {
|
|
DocumentID string `json:"document_id"`
|
|
RevisionID int `json:"revision_id"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type CreatedBlocks struct {
|
|
RevisionID int `json:"revision_id"`
|
|
}
|
|
|
|
type BitableSearchResult struct {
|
|
RecordID string `json:"record_id,omitempty"`
|
|
Found bool `json:"found"`
|
|
}
|
|
|
|
type BitableWriteResult struct {
|
|
RecordID string `json:"record_id,omitempty"`
|
|
Created bool `json:"created,omitempty"`
|
|
Updated bool `json:"updated,omitempty"`
|
|
}
|
|
|
|
type CreatedTask struct {
|
|
TaskID string `json:"task_id,omitempty"`
|
|
}
|
|
|
|
func NewOpenAPIClient(httpClient *http.Client) OpenAPIClient {
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
return OpenAPIClient{
|
|
BaseURL: openAPIBaseURL,
|
|
HTTP: httpClient,
|
|
}
|
|
}
|
|
|
|
func (c OpenAPIClient) TenantAccessToken(ctx context.Context, appID, appSecret string) (TenantToken, error) {
|
|
body := map[string]string{
|
|
"app_id": appID,
|
|
"app_secret": appSecret,
|
|
}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return TenantToken{}, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/auth/v3/tenant_access_token/internal"), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return TenantToken{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
TenantAccessToken string `json:"tenant_access_token"`
|
|
Expire int `json:"expire"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return TenantToken{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return TenantToken{}, fmt.Errorf("Feishu tenant token returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
if strings.TrimSpace(resp.TenantAccessToken) == "" {
|
|
return TenantToken{}, fmt.Errorf("Feishu tenant token response missing tenant_access_token")
|
|
}
|
|
return TenantToken{Value: resp.TenantAccessToken, Expire: resp.Expire}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) GetWikiNode(ctx context.Context, tenantToken string, wikiNodeToken string) (WikiNode, error) {
|
|
query := url.Values{}
|
|
query.Set("token", wikiNodeToken)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/wiki/v2/spaces/get_node")+"?"+query.Encode(), nil)
|
|
if err != nil {
|
|
return WikiNode{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Node WikiNode `json:"node"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return WikiNode{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return WikiNode{}, fmt.Errorf("Feishu wiki get_node returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
if strings.TrimSpace(resp.Data.Node.ObjToken) == "" {
|
|
return WikiNode{}, fmt.Errorf("Feishu wiki node response missing obj_token")
|
|
}
|
|
return resp.Data.Node, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) CreateDocument(ctx context.Context, tenantToken string, folderToken string, title string) (CreatedDocument, error) {
|
|
body := map[string]string{"title": title}
|
|
if strings.TrimSpace(folderToken) != "" {
|
|
body["folder_token"] = strings.TrimSpace(folderToken)
|
|
}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return CreatedDocument{}, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/docx/v1/documents"), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return CreatedDocument{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Document CreatedDocument `json:"document"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return CreatedDocument{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return CreatedDocument{}, fmt.Errorf("Feishu docx create returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
if strings.TrimSpace(resp.Data.Document.DocumentID) == "" {
|
|
return CreatedDocument{}, fmt.Errorf("Feishu docx create response missing document_id")
|
|
}
|
|
return resp.Data.Document, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) CreateBlocks(ctx context.Context, tenantToken string, documentID string, parentBlockID string, blocks []DocBlock) (CreatedBlocks, error) {
|
|
if strings.TrimSpace(parentBlockID) == "" {
|
|
parentBlockID = documentID
|
|
}
|
|
body := map[string]interface{}{"children": blocks}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return CreatedBlocks{}, err
|
|
}
|
|
path := fmt.Sprintf("/docx/v1/documents/%s/blocks/%s/children", url.PathEscape(documentID), url.PathEscape(parentBlockID))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return CreatedBlocks{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
RevisionID int `json:"revision_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return CreatedBlocks{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return CreatedBlocks{}, fmt.Errorf("Feishu docx create blocks returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
return CreatedBlocks{RevisionID: resp.Data.RevisionID}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) SearchBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, uniqueKey string) (BitableSearchResult, error) {
|
|
body := map[string]interface{}{
|
|
"filter": map[string]interface{}{
|
|
"conjunction": "and",
|
|
"conditions": []map[string]interface{}{
|
|
{
|
|
"field_name": "unique_key",
|
|
"operator": "is",
|
|
"value": []string{uniqueKey},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return BitableSearchResult{}, err
|
|
}
|
|
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records/search?page_size=1", url.PathEscape(appToken), url.PathEscape(tableID))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return BitableSearchResult{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Items []struct {
|
|
RecordID string `json:"record_id"`
|
|
} `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return BitableSearchResult{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return BitableSearchResult{}, fmt.Errorf("Feishu bitable search returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
if len(resp.Data.Items) == 0 || strings.TrimSpace(resp.Data.Items[0].RecordID) == "" {
|
|
return BitableSearchResult{Found: false}, nil
|
|
}
|
|
return BitableSearchResult{RecordID: resp.Data.Items[0].RecordID, Found: true}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) CreateBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, fields map[string]interface{}) (BitableWriteResult, error) {
|
|
body := map[string]interface{}{"fields": fields}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records", url.PathEscape(appToken), url.PathEscape(tableID))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(path), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Record struct {
|
|
RecordID string `json:"record_id"`
|
|
} `json:"record"`
|
|
RecordID string `json:"record_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return BitableWriteResult{}, fmt.Errorf("Feishu bitable create returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
recordID := firstNonEmpty(resp.Data.Record.RecordID, resp.Data.RecordID)
|
|
return BitableWriteResult{RecordID: recordID, Created: true}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) UpdateBitableRecord(ctx context.Context, tenantToken string, appToken string, tableID string, recordID string, fields map[string]interface{}) (BitableWriteResult, error) {
|
|
body := map[string]interface{}{"fields": fields}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
path := fmt.Sprintf("/bitable/v1/apps/%s/tables/%s/records/%s", url.PathEscape(appToken), url.PathEscape(tableID), url.PathEscape(recordID))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.endpoint(path), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Record struct {
|
|
RecordID string `json:"record_id"`
|
|
} `json:"record"`
|
|
RecordID string `json:"record_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return BitableWriteResult{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return BitableWriteResult{}, fmt.Errorf("Feishu bitable update returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
return BitableWriteResult{RecordID: firstNonEmpty(resp.Data.Record.RecordID, resp.Data.RecordID, recordID), Updated: true}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) CreateTask(ctx context.Context, tenantToken string, task TaskCandidate) (CreatedTask, error) {
|
|
body := map[string]interface{}{
|
|
"summary": task.Title,
|
|
"description": task.Description + taskLinkSuffix(task),
|
|
}
|
|
reqBody, err := json.Marshal(body)
|
|
if err != nil {
|
|
return CreatedTask{}, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/task/v2/tasks"), bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return CreatedTask{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
var resp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
Task struct {
|
|
GUID string `json:"guid"`
|
|
TaskID string `json:"task_id"`
|
|
} `json:"task"`
|
|
TaskID string `json:"task_id"`
|
|
GUID string `json:"guid"`
|
|
} `json:"data"`
|
|
}
|
|
if err := c.doJSON(req, &resp); err != nil {
|
|
return CreatedTask{}, err
|
|
}
|
|
if resp.Code != 0 {
|
|
return CreatedTask{}, fmt.Errorf("Feishu task create returned code %d: %s", resp.Code, resp.Msg)
|
|
}
|
|
return CreatedTask{TaskID: firstNonEmpty(resp.Data.Task.GUID, resp.Data.Task.TaskID, resp.Data.GUID, resp.Data.TaskID)}, nil
|
|
}
|
|
|
|
func (c OpenAPIClient) endpoint(path string) string {
|
|
base := strings.TrimRight(c.BaseURL, "/")
|
|
if base == "" {
|
|
base = strings.TrimRight(openAPIBaseURL, "/")
|
|
}
|
|
return base + path
|
|
}
|
|
|
|
func (c OpenAPIClient) doJSON(req *http.Request, target interface{}) error {
|
|
httpClient := c.HTTP
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
var decoded struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
detail := strings.TrimSpace(string(body))
|
|
if err := json.Unmarshal(body, &decoded); err == nil && decoded.Msg != "" {
|
|
detail = fmt.Sprintf("code %d: %s", decoded.Code, decoded.Msg)
|
|
}
|
|
if len(detail) > 300 {
|
|
detail = detail[:300] + "..."
|
|
}
|
|
return fmt.Errorf("Feishu OpenAPI %s %s returned HTTP %d: %s", req.Method, redactOpenAPIPath(req.URL.Path), resp.StatusCode, detail)
|
|
}
|
|
if err := json.Unmarshal(body, target); err != nil {
|
|
return fmt.Errorf("parse Feishu OpenAPI response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func redactOpenAPIPath(path string) string {
|
|
replacements := []struct {
|
|
pattern string
|
|
repl string
|
|
}{
|
|
{`/documents/[^/]+`, `/documents/...`},
|
|
{`/blocks/[^/]+`, `/blocks/...`},
|
|
{`/apps/[^/]+`, `/apps/...`},
|
|
{`/tables/[^/]+`, `/tables/...`},
|
|
{`/records/[^/]+`, `/records/...`},
|
|
{`/tasks/[^/]+`, `/tasks/...`},
|
|
}
|
|
for _, replacement := range replacements {
|
|
path = regexp.MustCompile(replacement.pattern).ReplaceAllString(path, replacement.repl)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func taskLinkSuffix(task TaskCandidate) string {
|
|
links := []string{}
|
|
if task.GitLinkURL != "" {
|
|
links = append(links, "GitLink: "+task.GitLinkURL)
|
|
}
|
|
if task.DocURL != "" {
|
|
links = append(links, "Feishu report: "+task.DocURL)
|
|
}
|
|
if len(links) == 0 {
|
|
return ""
|
|
}
|
|
return "\n\n" + strings.Join(links, "\n")
|
|
}
|
|
|
|
func diagnoseOpenAPIError(err error, category string, targetType string) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := err.Error()
|
|
likely := "check Feishu app scopes, resource permissions, IDs, and tenant availability"
|
|
switch category {
|
|
case "task create":
|
|
likely = "grant Task API scopes and verify task creation is enabled for the app"
|
|
case "bitable":
|
|
likely = "grant Base/Bitable scopes and verify app token, table ID, and unique_key field"
|
|
case "docx":
|
|
likely = "grant DocX/Drive scopes and write access to the target document, Wiki node, or folder"
|
|
}
|
|
return fmt.Sprintf("%s failed for %s: %s; likely reason: %s", category, targetType, message, likely)
|
|
}
|