feat: add feishu doc export

This commit is contained in:
whzy 2026-06-15 15:44:53 +08:00
parent c2a581eee9
commit d8cb4aa9d2
9 changed files with 979 additions and 4 deletions

130
docs/feishu-integration.md Normal file
View File

@ -0,0 +1,130 @@
# Feishu Integration
`gitlink-cli feishu` exports local GitLink workflow JSON to Feishu collaboration surfaces.
The commands are intentionally one-way:
```text
workflow JSON -> local preview
workflow JSON -> Feishu bot card
workflow JSON -> Feishu DocX / Wiki report
workflow JSON -> Bitable-ready dry-run records
```
## Safety Model
Default behavior is local preview.
Network operations require `--send`.
`--send` and `--dry-run` cannot be used together.
The implementation does not write to GitLink resources, does not close issues, does not comment on PRs, and does not merge code.
## Custom Bot
Use a Feishu custom group bot for notification cards.
Environment:
```powershell
$env:FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/..."
$env:FEISHU_WEBHOOK_SECRET="optional signing secret"
```
Preview:
```bash
gitlink-cli feishu +bot-test --format json
```
Send:
```bash
gitlink-cli feishu +bot-test --send --format table
```
Send a workflow report card:
```bash
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
Send a card with a DocX or Wiki link:
```bash
gitlink-cli feishu +notify \
--from-workflow-json report.json \
--doc-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```
## DocX / Wiki Export
Use a Feishu self-built app for document export.
Environment:
```powershell
$env:FEISHU_APP_ID="cli_xxx"
$env:FEISHU_APP_SECRET="..."
```
Preview:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--format markdown
```
Append report blocks to a Wiki-backed DocX:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```
Create a new DocX in a folder:
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--folder-token "<folder_token>" \
--title "GitLink workflow report" \
--send \
--format table
```
Required Feishu setup:
```text
1. The self-built app must have DocX / Drive application scopes.
2. The app permission version must be published and approved.
3. The target Wiki / DocX / folder must grant the app write permission.
```
If Feishu returns `1770032: forBidden`, the token is valid but the app cannot write to the target document. Grant the app access to that Wiki/DocX page or use a folder where the app can create documents.
## Bitable Dry Run
Generate recommended table schemas:
```bash
gitlink-cli feishu +bitable-schema --format markdown
```
Generate Bitable-ready records:
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
These commands do not call Bitable OpenAPI and do not create, update, or upsert records.

45
examples/feishu/README.md Normal file
View File

@ -0,0 +1,45 @@
# Feishu Export Examples
## 1. Generate Workflow JSON
```bash
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json > report.json
```
## 2. Preview Notification Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
## 3. Send Notification Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
## 4. Preview Wiki Export
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--format markdown
```
## 5. Export To Wiki
```bash
gitlink-cli feishu +doc-export \
--from-workflow-json report.json \
--wiki-url "https://example.feishu.cn/wiki/..." \
--send \
--format table
```
## 6. Generate Bitable Records
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```

View File

@ -207,6 +207,20 @@ Bot card = notification and entry link.
Bitable records = structured data for later dashboards.
```
Observed permission behavior:
```text
tenant_access_token acquisition succeeded.
Wiki get_node succeeded.
DocX create-block failed with HTTP 403 / code 1770032 / forBidden.
```
This means the design must include explicit permission diagnostics:
```text
The self-built app must have both approved DocX/Drive scopes and write access to the target Wiki/DocX page or folder.
```
## Bitable Real Write Requirements
Keep current `+bitable-schema` and `+bitable-records` as dry-run commands.

View File

@ -30,6 +30,11 @@ gitlink-cli feishu +bitable-records
- Added project activity card generation from workflow JSON.
- Added weekly report rendering from workflow JSON.
- Added `--doc-url` support for notification cards.
- Added `feishu +doc-export` for Feishu DocX / Wiki export.
- Added self-built app tenant token acquisition.
- Added Wiki node resolution.
- Added DocX block creation client.
- Added document export preview and explicit `--send` behavior.
- Added Bitable dry-run schema generation.
- Added Bitable-ready dry-run records.
- Registered the new shortcut group in `shortcuts/register.go`.
@ -74,6 +79,22 @@ object token: present
No document content was modified in this check.
The first real DocX block write attempt reached the DocX block endpoint but Feishu rejected the write:
```text
HTTP status: 403
Feishu code: 1770032
Message: forBidden
```
Interpretation:
```text
The app credentials are valid and the Wiki node is readable, but the app does not currently have write permission on the target Wiki-backed DocX page or lacks the required document scope approval.
```
The command now reports a permission hint for this case.
## Knowledge Base Design Update
Added official-docs alignment notes:
@ -128,9 +149,8 @@ DocX content write
## Next Engineering Step
Add `feishu +doc-export` as the first self-built app integration:
Complete the Feishu document permission setup and rerun:
```text
app_id/app_secret -> tenant_access_token -> resolve Wiki node or create DocX -> write report blocks -> return doc URL
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url <wiki_url> --send --format table
```

View File

@ -0,0 +1,329 @@
package feishu
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
type DocExportOptions struct {
AppID string `json:"-"`
AppSecret string `json:"-"`
FolderToken string `json:"folder_token,omitempty"`
DocumentID string `json:"document_id,omitempty"`
WikiURL string `json:"wiki_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
Title string `json:"title"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
}
type DocExportOutput struct {
Mode string `json:"mode"`
Send bool `json:"send"`
DryRun bool `json:"dry_run"`
TargetType string `json:"target_type"`
Operation string `json:"operation"`
Title string `json:"title"`
DocumentID string `json:"document_id,omitempty"`
DocumentURL string `json:"document_url,omitempty"`
WikiNodeToken string `json:"wiki_node_token,omitempty"`
WikiNode *WikiNodeSummary `json:"wiki_node,omitempty"`
BlockCount int `json:"block_count"`
TokenExpire int `json:"token_expire,omitempty"`
RevisionID int `json:"revision_id,omitempty"`
Preview string `json:"preview,omitempty"`
}
type WikiNodeSummary struct {
NodeType string `json:"node_type,omitempty"`
ObjType string `json:"obj_type,omitempty"`
Title string `json:"title,omitempty"`
}
type DocBlock map[string]interface{}
func docExportOptionsFromContext(ctx *common.RuntimeContext) (DocExportOptions, error) {
opts := DocExportOptions{
AppID: firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID")),
AppSecret: firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET")),
FolderToken: firstNonEmpty(ctx.Arg("folder-token"), os.Getenv("FEISHU_DOC_FOLDER_TOKEN")),
DocumentID: firstNonEmpty(ctx.Arg("document-id"), os.Getenv("FEISHU_DOCUMENT_ID")),
WikiURL: firstNonEmpty(ctx.Arg("wiki-url"), os.Getenv("FEISHU_WIKI_URL")),
WikiNodeToken: firstNonEmpty(ctx.Arg("wiki-node-token"), os.Getenv("FEISHU_WIKI_NODE_TOKEN")),
Title: strings.TrimSpace(ctx.Arg("title")),
Send: parseBool(ctx.Arg("send")),
DryRun: parseBool(ctx.Arg("dry-run")),
}
if opts.WikiNodeToken == "" && opts.WikiURL != "" {
opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL)
}
if opts.DocumentID == "" && opts.WikiURL != "" {
opts.DocumentID = docxTokenFromURL(opts.WikiURL)
}
if opts.Send && opts.DryRun {
return DocExportOptions{}, fmt.Errorf("--send and --dry-run cannot be used together")
}
if opts.Send {
if strings.TrimSpace(opts.AppID) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-id or FEISHU_APP_ID")
}
if strings.TrimSpace(opts.AppSecret) == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --app-secret or FEISHU_APP_SECRET")
}
if opts.FolderToken == "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return DocExportOptions{}, fmt.Errorf("--send requires --folder-token, --document-id, --wiki-url, or --wiki-node-token")
}
}
return opts, nil
}
func exportDocOrPreview(ctx *common.RuntimeContext, opts DocExportOptions, report workflow.RepoReportResult, lang string) error {
title := firstNonEmpty(opts.Title, "GitLink workflow report: "+report.Repository)
markdown, err := workflow.RenderRepoReport(report, "markdown", lang)
if err != nil {
return err
}
blocks := BuildDocBlocks(report, lang)
output := DocExportOutput{
Mode: "preview",
Send: opts.Send,
DryRun: !opts.Send,
TargetType: docTargetType(opts),
Operation: docOperation(opts),
Title: title,
DocumentID: opts.DocumentID,
DocumentURL: firstNonEmpty(opts.WikiURL),
BlockCount: len(blocks),
Preview: markdown,
}
if opts.WikiNodeToken != "" {
output.WikiNodeToken = opts.WikiNodeToken
}
if !opts.Send {
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "markdown"))
}
client := NewOpenAPIClient(http.DefaultClient)
token, err := client.TenantAccessToken(context.Background(), opts.AppID, opts.AppSecret)
if err != nil {
return err
}
output.TokenExpire = token.Expire
documentID := opts.DocumentID
if opts.WikiNodeToken != "" {
node, err := client.GetWikiNode(context.Background(), token.Value, opts.WikiNodeToken)
if err != nil {
return err
}
output.TargetType = "wiki"
if node.ObjType != "" && node.ObjType != "docx" {
return fmt.Errorf("Feishu wiki node object type %q is not supported; expected docx", node.ObjType)
}
documentID = node.ObjToken
output.DocumentID = documentID
output.WikiNode = &WikiNodeSummary{
NodeType: node.NodeType,
ObjType: node.ObjType,
Title: node.Title,
}
if output.DocumentURL == "" {
output.DocumentURL = node.URL
}
}
if documentID == "" {
created, err := client.CreateDocument(context.Background(), token.Value, opts.FolderToken, title)
if err != nil {
return err
}
documentID = created.DocumentID
output.DocumentID = created.DocumentID
output.DocumentURL = created.URL
output.RevisionID = created.RevisionID
output.Operation = "create"
}
createdBlocks, err := client.CreateBlocks(context.Background(), token.Value, documentID, documentID, blocks)
if err != nil {
return fmt.Errorf("%w\nhint: grant the Feishu self-built app edit access to the target DocX/Wiki page, or export to a folder where the app has document creation permission", err)
}
if createdBlocks.RevisionID != 0 {
output.RevisionID = createdBlocks.RevisionID
}
output.Mode = "sent"
output.DryRun = false
output.Preview = ""
return renderDocExportOutput(os.Stdout, output, formatOrDefault(ctx, "json"))
}
func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock {
healthScore := "N/A"
healthRisk := "N/A"
if report.Health != nil {
healthScore = fmt.Sprintf("%d", report.Health.HealthScore)
healthRisk = report.Health.RiskLevel
}
blocks := []DocBlock{
textBlock("GitLink workflow report: " + report.Repository),
textBlock(fmt.Sprintf("Report score: %d", report.ReportScore)),
textBlock("Risk level: " + firstNonEmpty(report.RiskLevel, "unknown")),
textBlock(fmt.Sprintf("Health score: %s; health risk: %s", healthScore, healthRisk)),
textBlock(fmt.Sprintf("Issues: total=%d, high_risk=%d, missing_info=%d", report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)),
textBlock(fmt.Sprintf("Pull Requests: total=%d, high_risk=%d", report.PRSummary.Total, report.PRSummary.HighRisk)),
}
if len(report.PRSummary.ReviewFocus) > 0 {
blocks = append(blocks, textBlock("Review focus:\n"+joinLines(report.PRSummary.ReviewFocus, 6)))
}
if len(report.Recommendations) > 0 {
blocks = append(blocks, textBlock("Recommendations:\n"+joinLines(report.Recommendations, 8)))
}
if len(report.Reasoning) > 0 {
blocks = append(blocks, textBlock("Reasoning:\n"+joinLines(report.Reasoning, 8)))
}
blocks = append(blocks, textBlock("Source: "+firstNonEmpty(report.Source, "workflow-json")))
return blocks
}
func textBlock(content string) DocBlock {
return DocBlock{
"block_type": 2,
"text": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"text_run": map[string]interface{}{
"content": content,
},
},
},
},
}
}
func renderDocExportOutput(w io.Writer, output DocExportOutput, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, output)
case "table":
return writeDocExportTable(w, output)
default:
return writeDocExportMarkdown(w, output)
}
}
func writeDocExportMarkdown(w io.Writer, output DocExportOutput) error {
if _, err := fmt.Fprintf(w, "# Feishu Doc Export\n\n"); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Mode: `%s`", output.Mode),
fmt.Sprintf("- Send: `%t`", output.Send),
fmt.Sprintf("- Dry run: `%t`", output.DryRun),
fmt.Sprintf("- Target: `%s`", output.TargetType),
fmt.Sprintf("- Operation: `%s`", output.Operation),
fmt.Sprintf("- Title: `%s`", output.Title),
fmt.Sprintf("- Blocks: `%d`", output.BlockCount),
}
if output.DocumentID != "" {
lines = append(lines, fmt.Sprintf("- Document ID: `%s`", output.DocumentID))
}
if output.DocumentURL != "" {
lines = append(lines, fmt.Sprintf("- Document URL: %s", output.DocumentURL))
}
if _, err := fmt.Fprintln(w, strings.Join(lines, "\n")); err != nil {
return err
}
if output.Preview != "" {
if _, err := fmt.Fprint(w, "\n## Preview\n\n"); err != nil {
return err
}
_, err := fmt.Fprint(w, output.Preview)
return err
}
return nil
}
func writeDocExportTable(w io.Writer, output DocExportOutput) error {
_, err := fmt.Fprintf(w, "MODE\tSEND\tDRY_RUN\tTARGET\tOPERATION\tBLOCKS\tDOCUMENT\n%s\t%t\t%t\t%s\t%s\t%d\t%s\n",
output.Mode,
output.Send,
output.DryRun,
output.TargetType,
output.Operation,
output.BlockCount,
firstNonEmpty(output.DocumentURL, output.DocumentID),
)
return err
}
func docTargetType(opts DocExportOptions) string {
switch {
case opts.WikiNodeToken != "":
return "wiki"
case opts.DocumentID != "":
return "docx"
case opts.FolderToken != "":
return "folder"
default:
return "preview"
}
}
func docOperation(opts DocExportOptions) string {
if opts.FolderToken != "" && opts.DocumentID == "" && opts.WikiNodeToken == "" {
return "create"
}
if opts.DocumentID != "" || opts.WikiNodeToken != "" {
return "append"
}
return "preview"
}
func wikiNodeTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "wiki" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func docxTokenFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, part := range parts {
if part == "docx" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func joinLines(values []string, limit int) string {
if limit <= 0 || limit > len(values) {
limit = len(values)
}
lines := make([]string, 0, limit)
for _, value := range values[:limit] {
value = strings.TrimSpace(value)
if value == "" {
continue
}
lines = append(lines, "- "+value)
}
return strings.Join(lines, "\n")
}

View File

@ -24,6 +24,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
newBotTestShortcut(),
newNotifyShortcut(),
newWeeklyReportShortcut(),
newDocExportShortcut(),
newBitableSchemaShortcut(),
newBitableRecordsShortcut(),
}
@ -72,6 +73,27 @@ func newWeeklyReportShortcut() *common.Shortcut {
}
}
func newDocExportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "doc-export",
Description: "Preview or export a workflow report to Feishu DocX or Wiki",
Flags: []common.Flag{
{Name: "from-workflow-json", Usage: "Read workflow repo report JSON from a file", Required: true},
{Name: "title", Usage: "Document title"},
{Name: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_DOC_FOLDER_TOKEN"},
{Name: "document-id", Usage: "Existing Feishu DocX document ID. Defaults to FEISHU_DOCUMENT_ID"},
{Name: "wiki-url", Usage: "Existing Feishu Wiki URL. Defaults to FEISHU_WIKI_URL"},
{Name: "wiki-node-token", Usage: "Existing Feishu Wiki node token. Defaults to FEISHU_WIKI_NODE_TOKEN"},
{Name: "app-id", Usage: "Feishu self-built app ID. Defaults to FEISHU_APP_ID"},
{Name: "app-secret", Usage: "Feishu self-built app secret. Defaults to FEISHU_APP_SECRET"},
{Name: "send", Usage: "Create or update a Feishu document. Without --send, preview locally", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Force local preview. Cannot be combined with --send", Bool: true, Default: "false"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: defaultLang},
},
Run: runDocExport,
}
}
func newBitableSchemaShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-schema",
@ -153,6 +175,18 @@ func runWeeklyReport(ctx *common.RuntimeContext) error {
return err
}
func runDocExport(ctx *common.RuntimeContext) error {
opts, err := docExportOptionsFromContext(ctx)
if err != nil {
return err
}
report, err := readWorkflowReport(ctx.Arg("from-workflow-json"), normalizeLang(ctx.Arg("lang")))
if err != nil {
return err
}
return exportDocOrPreview(ctx, opts, report, normalizeLang(ctx.Arg("lang")))
}
func runBitableSchema(ctx *common.RuntimeContext) error {
schema := BuildBitableSchema(parseList(firstNonEmpty(ctx.Arg("tables"), defaultTables)))
return renderBitableSchema(os.Stdout, schema, formatOrDefault(ctx, "markdown"))

View File

@ -11,6 +11,7 @@ import (
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestShortcutsExposeExpectedCommands(t *testing.T) {
@ -18,7 +19,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) {
for _, shortcut := range Shortcuts() {
got[shortcut.Name] = true
}
for _, name := range []string{"bot-test", "notify", "weekly-report", "bitable-schema", "bitable-records"} {
for _, name := range []string{"bot-test", "notify", "weekly-report", "doc-export", "bitable-schema", "bitable-records"} {
if !got[name] {
t.Fatalf("Shortcuts missing %s", name)
}
@ -136,3 +137,84 @@ func TestBitableSchemaAndRecords(t *testing.T) {
t.Fatalf("reports records = %d, want 1", len(records.Tables["reports"]))
}
}
func TestWikiNodeTokenFromURL(t *testing.T) {
got := wikiNodeTokenFromURL("https://tenant.feishu.cn/wiki/NodeToken123?from=from_copylink")
if got != "NodeToken123" {
t.Fatalf("wikiNodeTokenFromURL = %q", got)
}
}
func TestDocExportOptionsRequireAppCredentialsForSend(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",
"wiki-url": "https://tenant.feishu.cn/wiki/NodeToken123",
"app-id": "",
"app-secret": "",
}}
_, err := docExportOptionsFromContext(ctx)
if err == nil {
t.Fatal("expected missing app credential error")
}
}
func TestOpenAPIClientDocExportFlow(t *testing.T) {
var sawBlocks bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal":
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
case r.Method == http.MethodGet && r.URL.Path == "/wiki/v2/spaces/get_node":
if r.URL.Query().Get("token") != "NodeToken123" {
t.Fatalf("wiki token = %q", r.URL.Query().Get("token"))
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"node":{"space_id":"space","node_token":"NodeToken123","obj_token":"doc_token","obj_type":"docx","node_type":"origin","title":"Report"}}}`))
case r.Method == http.MethodPost && r.URL.Path == "/docx/v1/documents/doc_token/blocks/doc_token/children":
sawBlocks = true
var payload struct {
Children []DocBlock `json:"children"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode blocks: %v", err)
}
if len(payload.Children) == 0 {
t.Fatal("no blocks in payload")
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"revision_id":9}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := OpenAPIClient{BaseURL: server.URL, HTTP: server.Client()}
token, err := client.TenantAccessToken(context.Background(), "cli_xxx", "secret")
if err != nil {
t.Fatalf("TenantAccessToken returned error: %v", err)
}
node, err := client.GetWikiNode(context.Background(), token.Value, "NodeToken123")
if err != nil {
t.Fatalf("GetWikiNode returned error: %v", err)
}
if node.ObjToken != "doc_token" || node.ObjType != "docx" {
t.Fatalf("node = %+v", node)
}
blocks := BuildDocBlocks(workflowReportFixture(t), "en")
created, err := client.CreateBlocks(context.Background(), token.Value, node.ObjToken, node.ObjToken, blocks)
if err != nil {
t.Fatalf("CreateBlocks returned error: %v", err)
}
if created.RevisionID != 9 || !sawBlocks {
t.Fatalf("created = %+v sawBlocks=%t", created, sawBlocks)
}
}
func workflowReportFixture(t *testing.T) workflow.RepoReportResult {
t.Helper()
report, err := readWorkflowReport(filepath.Join("..", "workflow", "testdata", "repo_report.json"), "en")
if err != nil {
t.Fatalf("readWorkflowReport returned error: %v", err)
}
return report
}

242
shortcuts/feishu/openapi.go Normal file
View File

@ -0,0 +1,242 @@
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"`
}
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) 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/...`},
}
for _, replacement := range replacements {
path = regexp.MustCompile(replacement.pattern).ReplaceAllString(path, replacement.repl)
}
return path
}

View File

@ -0,0 +1,79 @@
---
name: gitlink-feishu
version: 1.0.0
description: "Export GitLink workflow JSON to Feishu bot cards, DocX/Wiki reports, and Bitable-ready dry-run records."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli feishu --help"
---
# gitlink-feishu
Use this skill when a user needs to send or export GitLink workflow analysis to Feishu.
## Rules
- Prefer local preview first.
- Use `--send` only when the user explicitly wants a Feishu network write.
- Never use BotBuilder or Robot Assistant workflows.
- Do not write to GitLink resources.
- Do not print webhook URLs, app secrets, or access tokens.
- Use `+bitable-records` for dry-run output only; do not claim that Bitable has been written.
## Workflow
Generate workflow JSON:
```bash
gitlink-cli workflow +repo-report --owner <owner> --repo <repo> --format json > report.json
```
Preview a Feishu card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
Send a Feishu card:
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
```
Preview a document export:
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --format markdown
```
Export to DocX or Wiki:
```bash
gitlink-cli feishu +doc-export --from-workflow-json report.json --wiki-url "<wiki_url>" --send --format table
```
Generate Bitable-ready records:
```bash
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
## Feishu Setup
Custom bot commands need:
```text
FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
DocX/Wiki export needs:
```text
FEISHU_APP_ID
FEISHU_APP_SECRET
```
The self-built app must also have permission to write the target DocX/Wiki page or folder.