feat: add feishu doc export
This commit is contained in:
parent
c2a581eee9
commit
d8cb4aa9d2
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue