diff --git a/docs/feishu-integration.md b/docs/feishu-integration.md new file mode 100644 index 0000000..c6b4f4b --- /dev/null +++ b/docs/feishu-integration.md @@ -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 "" \ + --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. + diff --git a/examples/feishu/README.md b/examples/feishu/README.md new file mode 100644 index 0000000..132e698 --- /dev/null +++ b/examples/feishu/README.md @@ -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 +``` + diff --git a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md index 22be782..69b7af7 100644 --- a/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md +++ b/feishu-export-design/OFFICIAL_DOCS_ALIGNMENT.md @@ -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. diff --git a/reports/FEISHU_TASK_COMPLETION.md b/reports/FEISHU_TASK_COMPLETION.md index 9a77c99..b4f0f69 100644 --- a/reports/FEISHU_TASK_COMPLETION.md +++ b/reports/FEISHU_TASK_COMPLETION.md @@ -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 --send --format table ``` - diff --git a/shortcuts/feishu/doc_export.go b/shortcuts/feishu/doc_export.go new file mode 100644 index 0000000..afba168 --- /dev/null +++ b/shortcuts/feishu/doc_export.go @@ -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") +} diff --git a/shortcuts/feishu/feishu.go b/shortcuts/feishu/feishu.go index c58f537..920504d 100644 --- a/shortcuts/feishu/feishu.go +++ b/shortcuts/feishu/feishu.go @@ -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")) diff --git a/shortcuts/feishu/feishu_test.go b/shortcuts/feishu/feishu_test.go index 92de326..54a33d9 100644 --- a/shortcuts/feishu/feishu_test.go +++ b/shortcuts/feishu/feishu_test.go @@ -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 +} diff --git a/shortcuts/feishu/openapi.go b/shortcuts/feishu/openapi.go new file mode 100644 index 0000000..ce25e4d --- /dev/null +++ b/shortcuts/feishu/openapi.go @@ -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 +} diff --git a/skills/gitlink-feishu/SKILL.md b/skills/gitlink-feishu/SKILL.md new file mode 100644 index 0000000..ba0f60c --- /dev/null +++ b/skills/gitlink-feishu/SKILL.md @@ -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 --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 "" --format markdown +``` + +Export to DocX or Wiki: + +```bash +gitlink-cli feishu +doc-export --from-workflow-json report.json --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. +