395 lines
16 KiB
Go
395 lines
16 KiB
Go
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
type DiagnosticOutput struct {
|
|
Mode string `json:"mode"`
|
|
Remote bool `json:"remote"`
|
|
Layer string `json:"layer"`
|
|
Summary DiagnosticSummary `json:"summary"`
|
|
Checks []DiagnosticCheck `json:"checks"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
|
|
type DiagnosticSummary struct {
|
|
Passed int `json:"passed"`
|
|
Warned int `json:"warned"`
|
|
Failed int `json:"failed"`
|
|
Skipped int `json:"skipped"`
|
|
}
|
|
|
|
type DiagnosticCheck struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Required bool `json:"required"`
|
|
Target string `json:"target,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
func runFeishuAppCheck(ctx *common.RuntimeContext) error {
|
|
output := DiagnosticOutput{
|
|
Mode: "check",
|
|
Remote: parseBool(ctx.Arg("remote")),
|
|
Layer: "app",
|
|
Warnings: []string{
|
|
"Diagnostic commands never write Feishu resources or GitLink resources.",
|
|
"Use --remote to call Feishu OpenAPI read/check endpoints.",
|
|
},
|
|
}
|
|
webhookURL := firstNonEmpty(ctx.Arg("webhook-url"), os.Getenv("FEISHU_WEBHOOK_URL"))
|
|
webhookSecret := firstNonEmpty(ctx.Arg("secret"), os.Getenv("FEISHU_WEBHOOK_SECRET"))
|
|
appID := firstNonEmpty(ctx.Arg("app-id"), os.Getenv("FEISHU_APP_ID"))
|
|
appSecret := firstNonEmpty(ctx.Arg("app-secret"), os.Getenv("FEISHU_APP_SECRET"))
|
|
|
|
if strings.TrimSpace(webhookURL) == "" {
|
|
output.addCheck(warnCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", "missing", "stable webhook send commands need FEISHU_WEBHOOK_URL"))
|
|
} else if err := validateWebhookURL(webhookURL); err != nil {
|
|
output.addCheck(failCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), err.Error(), "copy the full custom bot webhook URL from the Feishu group bot settings"))
|
|
} else {
|
|
output.addCheck(passCheck("custom bot webhook", "FEISHU_WEBHOOK_URL", redactWebhookURL(webhookURL), "configured"))
|
|
}
|
|
if strings.TrimSpace(webhookSecret) == "" {
|
|
output.addCheck(warnCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", "missing", "only required when the custom bot enables signature verification"))
|
|
} else {
|
|
output.addCheck(passCheck("custom bot signing secret", "FEISHU_WEBHOOK_SECRET", redactToken(webhookSecret), "configured"))
|
|
}
|
|
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", appID, "required for DocX, Bitable, and Task OpenAPI validation"))
|
|
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", appSecret, "required for tenant_access_token"))
|
|
if output.Remote && output.Summary.Failed == 0 {
|
|
output.remoteTenantToken(appID, appSecret)
|
|
} else if output.Remote {
|
|
output.addCheck(skipCheck("tenant_access_token", "Feishu OpenAPI", "skipped because app credentials are incomplete"))
|
|
}
|
|
return finishDiagnostic(ctx, output)
|
|
}
|
|
|
|
func runFeishuDocCheck(ctx *common.RuntimeContext) 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_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")),
|
|
}
|
|
if opts.WikiNodeToken == "" && opts.WikiURL != "" {
|
|
opts.WikiNodeToken = wikiNodeTokenFromURL(opts.WikiURL)
|
|
}
|
|
if opts.DocumentID == "" && opts.WikiURL != "" {
|
|
opts.DocumentID = docxTokenFromURL(opts.WikiURL)
|
|
}
|
|
output := DiagnosticOutput{
|
|
Mode: "check",
|
|
Remote: parseBool(ctx.Arg("remote")),
|
|
Layer: "docx",
|
|
Warnings: []string{
|
|
"Doc check does not append blocks or create documents.",
|
|
"Actual DocX/Wiki writes still require --send on +doc-export.",
|
|
},
|
|
}
|
|
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for DocX/Wiki OpenAPI"))
|
|
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
|
|
|
|
targets := 0
|
|
if opts.DocumentID != "" {
|
|
targets++
|
|
output.addCheck(passCheck("existing document target", "FEISHU_DOCUMENT_ID", redactToken(opts.DocumentID), "configured for append path"))
|
|
}
|
|
if opts.WikiNodeToken != "" {
|
|
targets++
|
|
output.addCheck(passCheck("wiki node target", "FEISHU_WIKI_NODE_TOKEN", redactToken(opts.WikiNodeToken), "configured or parsed from wiki URL"))
|
|
}
|
|
if opts.FolderToken != "" {
|
|
targets++
|
|
output.addCheck(passCheck("folder target", "FEISHU_FOLDER_TOKEN", redactToken(opts.FolderToken), "configured for create path"))
|
|
}
|
|
if opts.WikiURL != "" {
|
|
output.addCheck(passCheck("wiki url", "FEISHU_WIKI_URL", redactResourceURL(opts.WikiURL), "configured"))
|
|
}
|
|
if targets == 0 {
|
|
output.addCheck(failCheck("docx target", "DocX/Wiki", "missing", "no document, wiki, or folder target configured", "set FEISHU_DOCUMENT_ID, FEISHU_WIKI_URL, FEISHU_WIKI_NODE_TOKEN, or FEISHU_FOLDER_TOKEN"))
|
|
}
|
|
if output.Remote && output.Summary.Failed == 0 {
|
|
token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret)
|
|
if ok && opts.WikiNodeToken != "" {
|
|
output.remoteWikiNode(token.Value, opts.WikiNodeToken)
|
|
}
|
|
if ok && opts.WikiNodeToken == "" {
|
|
output.addCheck(skipCheck("wiki node read", "Feishu Wiki", "skipped because no wiki node token was configured"))
|
|
}
|
|
if ok && opts.DocumentID != "" {
|
|
output.addCheck(skipCheck("document edit permission", "Feishu DocX", "not checked without writing blocks"))
|
|
}
|
|
if ok && opts.FolderToken != "" {
|
|
output.addCheck(skipCheck("folder create permission", "Feishu Drive", "not checked without creating a document"))
|
|
}
|
|
} else if output.Remote {
|
|
output.addCheck(skipCheck("remote docx check", "Feishu OpenAPI", "skipped because required config is incomplete"))
|
|
}
|
|
return finishDiagnostic(ctx, output)
|
|
}
|
|
|
|
func runFeishuBitableCheck(ctx *common.RuntimeContext) error {
|
|
opts, err := bitableSyncOptionsFromContext(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
output := DiagnosticOutput{
|
|
Mode: "check",
|
|
Remote: parseBool(ctx.Arg("remote")),
|
|
Layer: "bitable",
|
|
Warnings: []string{
|
|
"Bitable check does not create, update, or delete records.",
|
|
"Remote mode searches a sentinel unique_key to verify table access and the unique_key field.",
|
|
},
|
|
}
|
|
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Base/Bitable OpenAPI"))
|
|
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
|
|
output.addCheck(requiredSecretCheck("base app token", "FEISHU_BASE_APP_TOKEN", opts.BaseAppToken, "required for Bitable tables"))
|
|
|
|
for _, table := range opts.Tables {
|
|
envName := tableEnvName(table)
|
|
tableID := opts.TableIDs[table]
|
|
output.addCheck(requiredSecretCheck(table+" table id", envName, tableID, "required for "+table+" sync"))
|
|
fields := schemaForTable(table).Fields
|
|
output.addCheck(passCheck(table+" expected fields", table, strings.Join(fieldNames(fields), ","), "schema expected by +bitable-sync"))
|
|
}
|
|
if output.Remote && output.Summary.Failed == 0 {
|
|
token, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret)
|
|
if ok {
|
|
client := NewOpenAPIClient(http.DefaultClient)
|
|
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for _, table := range opts.Tables {
|
|
_, err := client.SearchBitableRecord(checkCtx, token.Value, opts.BaseAppToken, opts.TableIDs[table], "__gitlink_cli_check__")
|
|
if err != nil {
|
|
output.addCheck(failCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), sanitizeDiagnostic(diagnoseOpenAPIError(err, "bitable", table), opts.AppID, opts.AppSecret, opts.BaseAppToken, opts.TableIDs[table]), "grant Base scopes, share the Base with the app, and ensure unique_key exists"))
|
|
continue
|
|
}
|
|
output.addCheck(passCheck("remote bitable table search", table, redactToken(opts.TableIDs[table]), "table accessible and unique_key search completed"))
|
|
}
|
|
}
|
|
} else if output.Remote {
|
|
output.addCheck(skipCheck("remote bitable check", "Feishu Base", "skipped because required config is incomplete"))
|
|
}
|
|
return finishDiagnostic(ctx, output)
|
|
}
|
|
|
|
func runFeishuTaskCheck(ctx *common.RuntimeContext) error {
|
|
opts, err := taskCreateOptionsFromContext(&common.RuntimeContext{Args: map[string]string{
|
|
"app-id": ctx.Arg("app-id"),
|
|
"app-secret": ctx.Arg("app-secret"),
|
|
"task-project-id": ctx.Arg("task-project-id"),
|
|
"task-section-id": ctx.Arg("task-section-id"),
|
|
}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
output := DiagnosticOutput{
|
|
Mode: "check",
|
|
Remote: parseBool(ctx.Arg("remote")),
|
|
Layer: "task",
|
|
Warnings: []string{
|
|
"Task check does not create tasks.",
|
|
"Current +task-create only sends basic summary and description.",
|
|
},
|
|
}
|
|
output.addCheck(requiredSecretCheck("self-built app id", "FEISHU_APP_ID", opts.AppID, "required for Task OpenAPI"))
|
|
output.addCheck(requiredSecretCheck("self-built app secret", "FEISHU_APP_SECRET", opts.AppSecret, "required for tenant_access_token"))
|
|
if opts.TaskProjectID == "" {
|
|
output.addCheck(warnCheck("task project id", "FEISHU_TASK_PROJECT_ID", "missing", "currently collected for future placement; not mapped into +task-create request body"))
|
|
} else {
|
|
output.addCheck(passCheck("task project id", "FEISHU_TASK_PROJECT_ID", redactToken(opts.TaskProjectID), "configured but not yet mapped into +task-create request body"))
|
|
}
|
|
if opts.TaskSectionID == "" {
|
|
output.addCheck(warnCheck("task section id", "FEISHU_TASK_SECTION_ID", "missing", "currently collected for future placement; not mapped into +task-create request body"))
|
|
} else {
|
|
output.addCheck(passCheck("task section id", "FEISHU_TASK_SECTION_ID", redactToken(opts.TaskSectionID), "configured but not yet mapped into +task-create request body"))
|
|
}
|
|
output.addCheck(warnCheck("task dedupe", "Feishu Task", "not implemented", "dedupe is local unique_key only; no Feishu-side task search/linking"))
|
|
if output.Remote && output.Summary.Failed == 0 {
|
|
if _, ok := output.remoteTenantToken(opts.AppID, opts.AppSecret); ok {
|
|
output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked without creating a task"))
|
|
} else {
|
|
output.addCheck(skipCheck("remote task create permission", "Feishu Task", "not checked because tenant_access_token acquisition failed"))
|
|
}
|
|
} else if output.Remote {
|
|
output.addCheck(skipCheck("remote task check", "Feishu Task", "skipped because app credentials are incomplete"))
|
|
}
|
|
return finishDiagnostic(ctx, output)
|
|
}
|
|
|
|
func finishDiagnostic(ctx *common.RuntimeContext, output DiagnosticOutput) error {
|
|
if err := renderDiagnosticOutput(os.Stdout, output, formatOrDefault(ctx, "table")); err != nil {
|
|
return err
|
|
}
|
|
if output.Summary.Failed > 0 {
|
|
return fmt.Errorf("Feishu %s check failed: %d failed check(s)", output.Layer, output.Summary.Failed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *DiagnosticOutput) remoteTenantToken(appID, appSecret string) (TenantToken, bool) {
|
|
client := NewOpenAPIClient(http.DefaultClient)
|
|
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
token, err := client.TenantAccessToken(checkCtx, appID, appSecret)
|
|
if err != nil {
|
|
o.addCheck(failCheck("tenant_access_token", "Feishu OpenAPI", "failed", sanitizeDiagnostic(diagnoseOpenAPIError(err, "tenant token", "self-built app"), appID, appSecret), "verify app_id/app_secret and app availability"))
|
|
return TenantToken{}, false
|
|
}
|
|
o.addCheck(passCheck("tenant_access_token", "Feishu OpenAPI", fmt.Sprintf("expire=%d", token.Expire), "acquired"))
|
|
return token, true
|
|
}
|
|
|
|
func (o *DiagnosticOutput) remoteWikiNode(token string, wikiNodeToken string) {
|
|
client := NewOpenAPIClient(http.DefaultClient)
|
|
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
node, err := client.GetWikiNode(checkCtx, token, wikiNodeToken)
|
|
if err != nil {
|
|
o.addCheck(failCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), sanitizeDiagnostic(diagnoseOpenAPIError(err, "docx", "wiki node"), wikiNodeToken, token), "grant Wiki/DocX scopes and make the target Wiki node visible to the app"))
|
|
return
|
|
}
|
|
detail := "obj_type=" + firstNonEmpty(node.ObjType, "unknown")
|
|
if node.Title != "" {
|
|
detail += "; title=" + node.Title
|
|
}
|
|
o.addCheck(passCheck("wiki node read", "Feishu Wiki", redactToken(wikiNodeToken), detail))
|
|
}
|
|
|
|
func (o *DiagnosticOutput) addCheck(check DiagnosticCheck) {
|
|
o.Checks = append(o.Checks, check)
|
|
switch check.Status {
|
|
case "pass":
|
|
o.Summary.Passed++
|
|
case "warn":
|
|
o.Summary.Warned++
|
|
case "fail":
|
|
o.Summary.Failed++
|
|
case "skip":
|
|
o.Summary.Skipped++
|
|
}
|
|
}
|
|
|
|
func requiredSecretCheck(name, target, value, hint string) DiagnosticCheck {
|
|
if strings.TrimSpace(value) == "" {
|
|
return failCheck(name, target, "missing", "required value is not configured", hint)
|
|
}
|
|
return passCheck(name, target, redactToken(value), "configured")
|
|
}
|
|
|
|
func passCheck(name, target, value, detail string) DiagnosticCheck {
|
|
return DiagnosticCheck{Name: name, Status: "pass", Target: target, Value: value, Detail: detail}
|
|
}
|
|
|
|
func warnCheck(name, target, value, hint string) DiagnosticCheck {
|
|
return DiagnosticCheck{Name: name, Status: "warn", Target: target, Value: value, Hint: hint}
|
|
}
|
|
|
|
func failCheck(name, target, value, detail, hint string) DiagnosticCheck {
|
|
return DiagnosticCheck{Name: name, Status: "fail", Required: true, Target: target, Value: value, Detail: detail, Hint: hint}
|
|
}
|
|
|
|
func skipCheck(name, target, detail string) DiagnosticCheck {
|
|
return DiagnosticCheck{Name: name, Status: "skip", Target: target, Detail: detail}
|
|
}
|
|
|
|
func renderDiagnosticOutput(w io.Writer, output DiagnosticOutput, format string) error {
|
|
switch normalizeFormat(format) {
|
|
case "markdown":
|
|
return writeDiagnosticMarkdown(w, output)
|
|
case "json":
|
|
return writeJSON(w, output)
|
|
default:
|
|
return writeDiagnosticTable(w, output)
|
|
}
|
|
}
|
|
|
|
func writeDiagnosticTable(w io.Writer, output DiagnosticOutput) error {
|
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
|
if _, err := fmt.Fprintf(tw, "LAYER\tREMOTE\tPASS\tWARN\tFAIL\tSKIP\n%s\t%t\t%d\t%d\t%d\t%d\n\n", output.Layer, output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintln(tw, "CHECK\tSTATUS\tTARGET\tVALUE\tDETAIL\tHINT"); err != nil {
|
|
return err
|
|
}
|
|
for _, check := range output.Checks {
|
|
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func writeDiagnosticMarkdown(w io.Writer, output DiagnosticOutput) error {
|
|
if _, err := fmt.Fprintf(w, "# Feishu %s Check\n\n", titleWord(output.Layer)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "- Remote: `%t`\n- Passed: `%d`\n- Warned: `%d`\n- Failed: `%d`\n- Skipped: `%d`\n\n", output.Remote, output.Summary.Passed, output.Summary.Warned, output.Summary.Failed, output.Summary.Skipped); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintln(w, "| Check | Status | Target | Value | Detail | Hint |\n| --- | --- | --- | --- | --- | --- |"); err != nil {
|
|
return err
|
|
}
|
|
for _, check := range output.Checks {
|
|
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %s |\n", check.Name, check.Status, check.Target, check.Value, oneLine(check.Detail), oneLine(check.Hint)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tableEnvName(table string) string {
|
|
switch table {
|
|
case "reports":
|
|
return "FEISHU_REPORT_TABLE_ID"
|
|
case "issues":
|
|
return "FEISHU_ISSUE_TABLE_ID"
|
|
case "prs":
|
|
return "FEISHU_PR_TABLE_ID"
|
|
case "contributors":
|
|
return "FEISHU_CONTRIBUTOR_TABLE_ID"
|
|
case "tasks":
|
|
return "FEISHU_TASK_TABLE_ID"
|
|
default:
|
|
return "FEISHU_TABLE_ID"
|
|
}
|
|
}
|
|
|
|
func fieldNames(fields []BitableField) []string {
|
|
names := make([]string, 0, len(fields))
|
|
for _, field := range fields {
|
|
names = append(names, field.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func sanitizeDiagnostic(message string, values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
message = strings.ReplaceAll(message, value, redactToken(value))
|
|
}
|
|
return message
|
|
}
|
|
|
|
func oneLine(value string) string {
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|