feat(feishu): add Open Platform readiness diagnostics

This commit is contained in:
whzy 2026-06-27 10:41:52 +08:00
parent 278b496118
commit d7812df1af
15 changed files with 778 additions and 52 deletions

View File

@ -10,6 +10,7 @@ $env:FEISHU_APP_SECRET=""
$env:FEISHU_WIKI_URL=""
$env:FEISHU_WIKI_NODE_TOKEN=""
$env:FEISHU_FOLDER_TOKEN=""
$env:FEISHU_DOCUMENT_ID=""
# Feishu Base / Bitable
$env:FEISHU_BASE_APP_TOKEN=""

View File

@ -582,6 +582,17 @@ gitlink-cli feishu +owner-digest --from-workflow-json report.zh-CN.json --lang z
gitlink-cli feishu +contributor-digest --from-workflow-json report.zh-CN.json --lang zh-CN --send --format table
```
#### 配置诊断层:先检查再写入
诊断命令用于降低飞书开放平台配置成本。默认只检查本地变量和目标配置;传 `--remote` 后会调用飞书只读/检查接口,例如获取 `tenant_access_token`、解析 Wiki node、搜索 Bitable sentinel `unique_key`。这些命令不会创建文档、不会写入多维表格、不会创建任务,也不会修改 GitLink。
```bash
gitlink-cli feishu +app-check --format table
gitlink-cli feishu +doc-check --remote --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table
gitlink-cli feishu +task-check --remote --format table
```
#### 实验层:飞书开放平台写入
实验层使用飞书开放平台自建应用。当前已在测试企业中验证 DocX 追加、多维表格写入和飞书任务创建,但这部分不是零配置稳定能力。真实写入仍然必须显式传 `--send`,并且要求自建应用有对应 API scope 和目标资源权限。

View File

@ -75,6 +75,52 @@ Unit and mock tests are implemented.
Real custom bot sending can be tested when FEISHU_WEBHOOK_URL exists.
```
## Layer 1.5: Configuration Diagnostics
Status: stable read/check surface.
Purpose:
```text
Help users discover missing env vars, invalid tokens, missing table IDs, missing
unique_key fields, and next-stage Task limitations before running --send writes.
```
Implemented commands:
```text
gitlink-cli feishu +app-check
gitlink-cli feishu +doc-check
gitlink-cli feishu +bitable-check
gitlink-cli feishu +task-check
```
Default behavior:
```text
Local checks only. No Feishu OpenAPI call is made unless --remote is explicit.
```
Remote behavior:
```text
--remote acquires tenant_access_token.
+doc-check --remote may resolve a Wiki node.
+bitable-check --remote searches a sentinel unique_key to verify table access
and the unique_key field.
+task-check --remote verifies app credentials only; it does not create tasks.
```
What it cannot do:
```text
Create Feishu resources.
Modify Feishu resources.
Modify GitLink resources.
Guarantee DocX edit permission without an actual append.
Guarantee Feishu Task project/section placement.
```
## Layer 2: Experimental Open Platform Validation
Status: experimental validation surface.
@ -120,6 +166,10 @@ FEISHU_TASK_SECTION_ID optional
Implemented commands:
```text
gitlink-cli feishu +app-check
gitlink-cli feishu +doc-check
gitlink-cli feishu +bitable-check
gitlink-cli feishu +task-check
gitlink-cli feishu +doc-export
gitlink-cli feishu +bitable-sync
gitlink-cli feishu +task-create

View File

@ -42,8 +42,8 @@ $env:FEISHU_WEBHOOK_SECRET="REDACTED"
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` | `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page |
| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` | same as above | Yes | Feishu Open Platform app credentials |
| `FEISHU_APP_ID` | Self-built app ID | Required for Open Platform `--send` or diagnostic `--remote` | `+app-check`, `+doc-check`, `+bitable-check`, `+task-check`, `+doc-export`, `+bitable-sync`, `+task-create` | Yes | Feishu Open Platform app page |
| `FEISHU_APP_SECRET` | Self-built app secret | Required for Open Platform `--send` or diagnostic `--remote` | same as above | Yes | Feishu Open Platform app credentials |
Example:
@ -56,10 +56,10 @@ $env:FEISHU_APP_SECRET="REDACTED"
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki |
| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-export` | Yes | Parsed from Wiki URL or API |
| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs |
| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs |
| `FEISHU_WIKI_URL` | Existing Wiki page URL | Optional target | `+doc-check`, `+doc-export` | Can expose workspace/resource ID | Copy from Feishu Wiki |
| `FEISHU_WIKI_NODE_TOKEN` | Existing Wiki node token | Optional target | `+doc-check`, `+doc-export` | Yes | Parsed from Wiki URL or API |
| `FEISHU_FOLDER_TOKEN` | Folder token for creating a new DocX | Optional target | `+doc-check`, `+doc-export` | Yes | Feishu Drive folder URL / Open Platform docs |
| `FEISHU_DOCUMENT_ID` | Existing DocX document ID for append | Optional target | `+doc-check`, `+doc-export` | Yes | Existing Feishu DocX URL or Open Platform docs |
Legacy compatibility:
@ -87,12 +87,12 @@ go run . feishu +notify --from-workflow-json .local\report.zh-CN.json --lang zh-
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-sync --send` | `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs |
| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when syncing `reports` | `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when syncing `issues` | `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when syncing `prs` | `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional | `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional | `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_BASE_APP_TOKEN` | Base app token | Required for `+bitable-check` and `+bitable-sync --send` | `+bitable-check`, `+bitable-sync` | Yes | Feishu Base URL / Open Platform docs |
| `FEISHU_REPORT_TABLE_ID` | Reports table ID | Required when checking or syncing `reports` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_ISSUE_TABLE_ID` | Issues table ID | Required when checking or syncing `issues` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_PR_TABLE_ID` | Pull request table ID | Required when checking or syncing `prs` | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_CONTRIBUTOR_TABLE_ID` | Contributors table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
| `FEISHU_TASK_TABLE_ID` | Task-candidate table ID | Optional unless selected | `+bitable-check`, `+bitable-sync` | Yes | Base table settings / API |
Example:
@ -109,8 +109,8 @@ $env:FEISHU_TASK_TABLE_ID="REDACTED"
| Name | Purpose | Required | Used by | Sensitive | How to obtain |
| --- | --- | --- | --- | --- | --- |
| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-create` | Yes | Feishu Task project settings / API |
| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-create` | Yes | Feishu Task section settings / API |
| `FEISHU_TASK_PROJECT_ID` | Optional task project target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task project settings / API |
| `FEISHU_TASK_SECTION_ID` | Optional task section target | Optional | `+task-check`, `+task-create` | Yes | Feishu Task section settings / API |
Current limitation:

View File

@ -56,11 +56,11 @@ https://www.feishu.cn/feishu-cli
| --- | --- | --- | --- | --- | --- |
| Custom bot webhook | `POST /open-apis/bot/v2/hook/{token}` | `+bot-test`, `+notify`, `+weekly-report`, `+owner-digest`, `+contributor-digest` | Implemented stable | Feishu chat message | Requires `--send`; preview by default |
| Custom bot signature | timestamp + HMAC-SHA256 signing secret | same as above | Implemented stable | Request signature only | `FEISHU_WEBHOOK_SECRET` optional |
| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+doc-export`, `+bitable-sync`, `+task-create` | Implemented experimental | Tenant token | No token cache yet |
| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-export` | Implemented experimental | Wiki metadata read | Used to resolve Wiki node to DocX object token |
| Tenant token | `POST /auth/v3/tenant_access_token/internal` | `+app-check --remote`, `+doc-check --remote`, `+bitable-check --remote`, `+task-check --remote`, `+doc-export`, `+bitable-sync`, `+task-create` | Implemented | Tenant token | No token cache yet |
| Wiki node resolution | `GET /wiki/v2/spaces/get_node?token=...` | `+doc-check --remote`, `+doc-export` | Implemented | Wiki metadata read | Used to resolve Wiki node to DocX object token |
| DocX create | `POST /docx/v1/documents` | `+doc-export` | Implemented experimental | New DocX document | Requires folder/resource permission |
| DocX append blocks | `POST /docx/v1/documents/{document_id}/blocks/{block_id}/children` | `+doc-export` | Implemented experimental | DocX block tree | Real write can fail on scope or document permission |
| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-sync` | Implemented experimental | Existing Base table | Searches by `unique_key` field |
| Bitable search | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records/search` | `+bitable-check --remote`, `+bitable-sync` | Implemented | Existing Base table | `+bitable-check` searches a sentinel key without writing |
| Bitable create record | `POST /bitable/v1/apps/{app_token}/tables/{table_id}/records` | `+bitable-sync` | Implemented experimental | Existing Base table | No table/field/view creation |
| Bitable update record | `PUT /bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}` | `+bitable-sync` | Implemented experimental | Existing Base table | Never deletes records |
| Task create | `POST /task/v2/tasks` | `+task-create` | Implemented experimental | Feishu task | Project/section placement is not mapped into request body yet |
@ -137,6 +137,10 @@ Current commands:
+doc-export
+bitable-sync
+task-create
+app-check
+doc-check
+bitable-check
+task-check
```
Inputs:
@ -157,7 +161,8 @@ Does not print the raw token.
Next hardening:
```text
Add +app-check.
`+app-check` now exists. Add richer scope-name hints once official scope names
are mapped in the code.
Cache token in memory during one command execution only.
Add scope diagnostics where official scope names are confirmed.
```
@ -220,7 +225,8 @@ must be handled by the owner/admin outside the CLI.
Next hardening:
```text
Add +app-check diagnostics for DocX/Wiki scopes.
`+doc-check` now validates DocX/Wiki target configuration and can resolve Wiki
nodes with --remote.
Add clearer output for target type: wiki node, existing doc, folder creation.
Add optional markdown-only export for manual paste into Feishu Docs.
```
@ -240,6 +246,7 @@ Current commands:
```text
+bitable-schema
+bitable-records
+bitable-check
+bitable-sync
```
@ -272,6 +279,9 @@ Current behavior:
```text
+bitable-schema outputs a dry-run schema.
+bitable-records outputs summary-oriented local records.
+bitable-check validates configured table IDs and expected fields.
+bitable-check --remote searches a sentinel unique_key to verify table access
and the unique_key field without writing records.
+bitable-sync previews by default.
+bitable-sync --send searches by unique_key, updates if found, creates if missing.
If search fails, the command falls back to create-only for that record.
@ -317,7 +327,8 @@ Current records are summary buckets, not full row-level PR/Issue/CI records.
Next hardening:
```text
Add table/field validation before writes.
`+bitable-check` now provides pre-write table ID and unique_key search
diagnostics. Field type validation still depends on richer Feishu field metadata.
Add row-level records for PRs, Issues, CI runs, milestones, releases, and audits.
Add optional Bitable view planning output for Kanban, Gantt, Calendar, Gallery, Form, and Dashboard.
Keep real view creation as a separate permissioned task.
@ -335,6 +346,7 @@ Current commands:
```text
+task-preview
+task-check
+task-create
```
@ -352,6 +364,8 @@ Current behavior:
```text
+task-preview generates local task candidates.
+task-check validates app credentials and documents current project/section and
dedupe limitations without creating tasks.
+task-create previews by default and creates tasks only with --send.
Task candidates are derived from workflow recommendations, high-risk issues,
missing-info issues, high-risk PRs, and review-focus items.
@ -386,7 +400,9 @@ Next hardening:
```text
Confirm official Task project/section placement fields.
Add Feishu-side dedupe or external unique_key linking when a stable API path exists.
Add scope diagnostics through +app-check.
`+task-check --remote` validates app credentials without creating tasks. Add
scope diagnostics and project/section request mapping after the official fields
are confirmed.
```
## i18n Validation
@ -589,6 +605,7 @@ GitLink real data validation:
[x] DocX create and block append APIs identified and implemented.
[x] Bitable record search/create/update APIs identified and implemented.
[x] Task create API identified and implemented at minimal summary/description level.
[x] Read/check diagnostics implemented for app, DocX/Wiki, Bitable, and Task setup.
[x] IM app bot send API identified as planned, not implemented.
[x] Card callback/event subscription identified as future, not implemented.
[x] User identity APIs identified as future, not implemented.

View File

@ -25,6 +25,7 @@ Real DocX append: passed through self-built app OpenAPI.
Real Bitable sync: passed after target table fields were created.
Split Bitable sync: passed with five separate tables.
Real Task create: passed; project/section placement remains unmapped.
Read/check diagnostics: passed for app, DocX target, Bitable tables, and Task credentials.
GitLink write operations: not implemented and not tested.
Test enterprise permissions: intentionally broad for validation; production should use minimum scopes.
```
@ -35,7 +36,7 @@ Test enterprise permissions: intentionally broad for validation; production shou
| --- | --- | --- | --- |
| Custom bot webhook | Complete and real-tested | `shortcuts/feishu/client.go`, `sign.go`, `card.go` | Image evidence deferred |
| Custom bot signing | Complete and real-tested | `SignCustomBotRequest` unit test plus signed bot smoke | Keep secrets redacted |
| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken` | Add future `+app-check` |
| tenant_access_token | Complete and real-tested | `OpenAPIClient.TenantAccessToken`, `+app-check --remote` | Add richer scope hints later |
| Wiki node resolution | Complete | `OpenAPIClient.GetWikiNode` | Still depends on target Wiki node permission |
| DocX create | Complete | `OpenAPIClient.CreateDocument` | Requires folder permission when creating new docs |
| DocX block append | Complete and real-tested | `OpenAPIClient.CreateBlocks` | App must have target DocX edit permission |
@ -57,6 +58,10 @@ Test enterprise permissions: intentionally broad for validation; production shou
| `feishu +weekly-report` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +owner-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +contributor-digest` | Stable | Implemented | Only with `--send` | `FEISHU_WEBHOOK_URL` |
| `feishu +app-check` | Stable diagnostics | Implemented | No writes; optional read/check with `--remote` | App credentials for remote token check |
| `feishu +doc-check` | Stable diagnostics | Implemented | No writes; optional Wiki read with `--remote` | App credentials and DocX/Wiki target |
| `feishu +bitable-check` | Stable diagnostics | Implemented | No writes; optional Bitable search with `--remote` | Base app token, table IDs, `unique_key` |
| `feishu +task-check` | Stable diagnostics | Implemented | No writes; optional token check with `--remote` | App credentials |
| `feishu +bitable-schema` | Stable dry-run | Implemented | No | No |
| `feishu +bitable-records` | Stable dry-run | Implemented | No | No |
| `feishu +task-preview` | Stable dry-run | Implemented | No | No |
@ -75,6 +80,7 @@ Test enterprise permissions: intentionally broad for validation; production shou
6. User-required environment variables are documented.
7. Resource-level permission requirements are documented.
8. Task project/section limitation is explicitly called out.
9. Feishu configuration diagnostics are available before running --send writes.
```
## What Still Needs User Action
@ -157,6 +163,10 @@ Executed on 2026-06-26 after the API inventory update:
| `+weekly-report --send` | Pass | Custom bot delivered weekly report |
| `+owner-digest --send` | Pass | Custom bot delivered English/default and Chinese owner digest |
| `+contributor-digest --send` | Pass | Custom bot delivered English/default and Chinese contributor digest |
| `+app-check --remote` | Pass | Custom bot, app credentials, and tenant_access_token validated with redacted output |
| `+doc-check --remote` | Pass | App credentials and DocX/folder targets checked; write permission not probed without appending |
| `+bitable-check --remote` | Pass | Five split tables passed sentinel `unique_key` search without record writes |
| `+task-check --remote` | Pass with warnings | Tenant token passed; project/section/dedupe remain next-stage boundaries |
| `+bitable-sync` preview | Pass | 1 report, 5 issue, 2 PR, 1 contributor, 7 task records |
| `+bitable-sync --send` | Pass | Search/create/update real-tested after field creation |
| Split-table `+bitable-sync --send` | Pass | Created 1 report, 5 issue, 2 PR, 1 contributor, and 7 task records across five separate Bitable tables |

View File

@ -45,7 +45,30 @@ This file is ignored and must not be committed. A tracked empty example is avail
The checker prints only redacted values.
## 3. Configure GitLink Test Repository Manually If Needed
## 3. Run Feishu CLI Diagnostics
Local diagnostics:
```bash
gitlink-cli feishu +app-check --format table
gitlink-cli feishu +doc-check --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --format table
gitlink-cli feishu +task-check --format table
```
Optional remote diagnostics:
```bash
gitlink-cli feishu +app-check --remote --format table
gitlink-cli feishu +doc-check --remote --format table
gitlink-cli feishu +bitable-check --tables reports,issues,prs,contributors,tasks --remote --format table
gitlink-cli feishu +task-check --remote --format table
```
Remote diagnostics call only read/check endpoints. They do not create DocX
blocks, Bitable records, Feishu tasks, or GitLink writes.
## 4. Configure GitLink Test Repository Manually If Needed
```powershell
$env:GITLINK_OWNER="OWNER"
@ -58,7 +81,7 @@ If the current workflow command cannot filter specific PR IDs, keep the PR IDs i
$env:GITLINK_TEST_PR_IDS="1,2,3"
```
## 4. Run Scripted Smoke Tests
## 5. Run Scripted Smoke Tests
Preview only:
@ -94,7 +117,7 @@ reports/feishu-real-smoke-terminal.log
The terminal log is ignored and must not be committed after real runs.
## 5. Generate Workflow Report JSON Manually
## 6. Generate Workflow Report JSON Manually
```bash
gitlink-cli workflow +repo-report \
@ -105,13 +128,13 @@ gitlink-cli workflow +repo-report \
Windows PowerShell redirection may produce UTF-16 with BOM. The Feishu workflow JSON reader supports UTF-8 and UTF-16 BOM inputs.
## 6. Preview Feishu Notify Card
## 7. Preview Feishu Notify Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --format json
```
## 7. Send Feishu Notify Card
## 8. Send Feishu Notify Card
```bash
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
@ -124,50 +147,50 @@ FEISHU_WEBHOOK_URL
FEISHU_WEBHOOK_SECRET optional
```
## 8. Render Weekly Report
## 9. Render Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
```
## 9. Send Weekly Report
## 10. Send Weekly Report
```bash
gitlink-cli feishu +weekly-report --from-workflow-json report.json --send --format table
```
## 10. Generate Owner Digest
## 11. Generate Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
```
## 11. Send Owner Digest
## 12. Send Owner Digest
```bash
gitlink-cli feishu +owner-digest --from-workflow-json report.json --send --format table
```
## 12. Generate Contributor Digest
## 13. Generate Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --format markdown
```
## 13. Send Contributor Digest
## 14. Send Contributor Digest
```bash
gitlink-cli feishu +contributor-digest --from-workflow-json report.json --send --format table
```
## 14. Generate Bitable-Ready Records
## 15. Generate Bitable-Ready Records
```bash
gitlink-cli feishu +bitable-schema --tables reports,issues,prs,contributors,tasks --format markdown
gitlink-cli feishu +bitable-records --from-workflow-json report.json --format json
```
## 15. Preview Bitable Sync
## 16. Preview Bitable Sync
```bash
gitlink-cli feishu +bitable-sync \
@ -176,7 +199,7 @@ gitlink-cli feishu +bitable-sync \
--format table
```
## 16. Execute Bitable Sync
## 17. Execute Bitable Sync
```bash
gitlink-cli feishu +bitable-sync \
@ -199,7 +222,7 @@ FEISHU_CONTRIBUTOR_TABLE_ID optional
FEISHU_TASK_TABLE_ID optional
```
## 17. Preview DocX / Wiki Export
## 18. Preview DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
@ -208,7 +231,7 @@ gitlink-cli feishu +doc-export \
--format markdown
```
## 18. Execute DocX / Wiki Export
## 19. Execute DocX / Wiki Export
```bash
gitlink-cli feishu +doc-export \
@ -218,13 +241,13 @@ gitlink-cli feishu +doc-export \
--format table
```
## 19. Preview Feishu Tasks
## 20. Preview Feishu Tasks
```bash
gitlink-cli feishu +task-preview --from-workflow-json report.json --format markdown
```
## 20. Create Feishu Tasks
## 21. Create Feishu Tasks
```bash
gitlink-cli feishu +task-create --from-workflow-json report.json --send --format table
@ -239,12 +262,12 @@ FEISHU_TASK_PROJECT_ID optional
FEISHU_TASK_SECTION_ID optional
```
## 21. Image Evidence
## 22. Image Evidence
Image evidence is deferred for this round. Do not add screenshots or image files
to the upload.
## 22. Run Go Tests
## 23. Run Go Tests
```bash
gofmt -w shortcuts/feishu
@ -254,7 +277,7 @@ go test ./shortcuts
go test ./...
```
## 23. Capture Evidence
## 24. Capture Evidence
Capture terminal logs and command output only.

View File

@ -11,6 +11,10 @@ GitLink write permission is `No` for every implemented command in this branch.
| Weekly report | `feishu +weekly-report` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | preview and real send passed | Card is summary-level |
| Owner digest | `feishu +owner-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not personalized |
| Contributor digest | `feishu +contributor-digest` | Stable webhook export | Yes for `--send` | No | No | No | No | No | No | Yes | unit, preview, and real send passed, including zh-CN | Role-oriented, not open_id routed |
| App diagnostics | `feishu +app-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No | No | No | Yes | unit, mock remote, and real remote passed | Remote mode only gets tenant_access_token; no writes |
| Doc diagnostics | `feishu +doc-check` | Stable diagnostics | No | Yes for `--remote` | Yes for Wiki node read | No | No | No | No | Yes | local and real remote diagnostics passed | Does not prove edit permission without append |
| Bitable diagnostics | `feishu +bitable-check` | Stable diagnostics | No | Yes for `--remote` | No | Yes for remote search | No | No | No | Yes | unit, mock remote, and five-table real remote passed | Checks table access and unique_key search; does not create fields |
| Task diagnostics | `feishu +task-check` | Stable diagnostics | No | Yes for `--remote` | No | No | No; token check only | No | No | Yes | mock remote and real remote passed with expected warnings | Does not create tasks; project/section still next-stage |
| Bitable schema | `feishu +bitable-schema` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Does not create tables or views |
| Bitable records | `feishu +bitable-records` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed | Summary records, not one row per raw issue/PR |
| Task preview | `feishu +task-preview` | Stable dry-run | No | No | No | No | No | No | No | Yes | preview passed, including zh-CN | Local candidates only |

View File

@ -104,6 +104,10 @@ The workflow command does not currently filter the report by explicit PR IDs, so
| `feishu +notify --lang zh-CN --send` | pass | Chinese workflow card delivered |
| `feishu +owner-digest --lang zh-CN --send` | pass | Chinese owner digest delivered |
| `feishu +contributor-digest --lang zh-CN --send` | pass | Chinese contributor digest delivered |
| `feishu +app-check --remote` | pass | custom bot, app credentials, and tenant_access_token checked with redacted output |
| `feishu +doc-check --remote` | pass | app credentials and configured DocX/folder targets checked; edit/create permission intentionally not checked without writing |
| `feishu +bitable-check --remote` | pass | five split Bitable tables passed sentinel `unique_key` search without writing records |
| `feishu +task-check --remote` | pass with warnings | app credentials and tenant_access_token checked; project/section/dedupe remain next-stage boundaries |
| `feishu +doc-export --send` | pass | appended 9 DocX blocks to the configured document |
| `feishu +doc-export --lang zh-CN --send` | pass | appended 9 localized DocX blocks |
| `feishu +bitable-sync --tables reports --send` | pass after table fields were added | created the report record |
@ -200,6 +204,8 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn.
| `go test ./shortcuts/workflow` | pass |
| `go test ./shortcuts` | pass |
| `go test ./...` | pass |
| `go build .` | pass |
| `go vet ./...` | pass |
| Raw secret scan over tracked/unignored candidate files | pass |
## Known Limitations
@ -208,11 +214,12 @@ module. It was not fixed in this smoke run to avoid unrelated locale churn.
1. Bitable sync requires existing Base/table/fields; CLI does not create tables or views.
2. The first smoke used one test table for all record groups; a later smoke created split tables and proved every record group can write to its own table.
3. Current Bitable records are summary buckets, not row-level PR/Issue/CI records.
4. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary.
5. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable.
6. No Feishu callback server is implemented.
7. No GitLink write operation is implemented.
8. Image evidence is deferred and is not part of this upload.
4. `+bitable-check --remote` can verify table access and unique_key search before writes, but it still does not create fields or validate every field type.
5. Feishu task creation does not yet map project/section placement into the request body; this is a next-stage capability boundary.
6. Feishu-side task dedupe/search is not implemented; avoid repeated real task-create runs unless duplicates are acceptable.
7. No Feishu callback server is implemented.
8. No GitLink write operation is implemented.
9. Image evidence is deferred and is not part of this upload.
```
## Image Evidence

View File

@ -68,7 +68,7 @@ if ($Layer -eq "stable" -or $Layer -eq "all") {
if ($Layer -eq "open-platform" -or $Layer -eq "all") {
$allMissing += Test-Group -Title "Open Platform app" -Names @("FEISHU_APP_ID", "FEISHU_APP_SECRET")
$allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN")
$allMissing += Test-Group -Title "DocX / Wiki" -Names @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID") -Optional @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID")
$allMissing += Test-Group -Title "Base / Bitable" -Names @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID", "FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID") -Optional @("FEISHU_CONTRIBUTOR_TABLE_ID", "FEISHU_TASK_TABLE_ID")
$allMissing += Test-Group -Title "Feishu Task" -Names @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID") -Optional @("FEISHU_TASK_PROJECT_ID", "FEISHU_TASK_SECTION_ID")
}

View File

@ -112,12 +112,13 @@ if (Missing @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) {
$env:FEISHU_APP_SECRET = Read-SecretValue "FEISHU_APP_SECRET" (Env "FEISHU_APP_SECRET") "Self-built app secret."
}
if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN")) {
if (Missing @("FEISHU_WIKI_URL", "FEISHU_WIKI_NODE_TOKEN", "FEISHU_FOLDER_TOKEN", "FEISHU_DOCUMENT_ID")) {
Open-Url "https://www.feishu.cn/" "Feishu Docs / Wiki"
Pause-User "Open your target Wiki/Doc page or folder and copy the URL/token. Press Enter when ready."
$env:FEISHU_WIKI_URL = Read-OptionalValue "FEISHU_WIKI_URL" (Env "FEISHU_WIKI_URL") "Existing Wiki or Doc URL for +doc-export."
$env:FEISHU_WIKI_NODE_TOKEN = Read-OptionalValue "FEISHU_WIKI_NODE_TOKEN" (Env "FEISHU_WIKI_NODE_TOKEN") "Optional. Usually parsed from FEISHU_WIKI_URL."
$env:FEISHU_FOLDER_TOKEN = Read-OptionalValue "FEISHU_FOLDER_TOKEN" (Env "FEISHU_FOLDER_TOKEN") "Optional. Used when creating a new DocX in a folder."
$env:FEISHU_DOCUMENT_ID = Read-OptionalValue "FEISHU_DOCUMENT_ID" (Env "FEISHU_DOCUMENT_ID") "Optional. Existing DocX document ID for append."
}
if (Missing @("FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) {
@ -165,6 +166,7 @@ $vars = @(
"FEISHU_WIKI_URL",
"FEISHU_WIKI_NODE_TOKEN",
"FEISHU_FOLDER_TOKEN",
"FEISHU_DOCUMENT_ID",
"FEISHU_BASE_APP_TOKEN",
"FEISHU_REPORT_TABLE_ID",
"FEISHU_ISSUE_TABLE_ID",

View File

@ -269,8 +269,12 @@ try {
$helpCommands = @(
"+owner-digest",
"+contributor-digest",
"+app-check",
"+doc-check",
"+bitable-check",
"+bitable-sync",
"+task-preview",
"+task-check",
"+task-create"
)
Invoke-Cmd "feishu help" @("go", "run", ".", "feishu", "--help") $true
@ -306,7 +310,26 @@ try {
}
if ($runOpenPlatform) {
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN"))) {
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET")) {
Invoke-Cmd "app-check remote" @("go", "run", ".", "feishu", "+app-check", "--remote", "--format", "table") $false
Invoke-Cmd "task-check remote" @("go", "run", ".", "feishu", "+task-check", "--remote", "--format", "table") $false
} else {
Add-Skip "app/task diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) {
Invoke-Cmd "doc-check remote" @("go", "run", ".", "feishu", "+doc-check", "--remote", "--format", "table") $false
} else {
Add-Skip "doc diagnostics" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_BASE_APP_TOKEN", "FEISHU_REPORT_TABLE_ID", "FEISHU_ISSUE_TABLE_ID", "FEISHU_PR_TABLE_ID")) {
Invoke-Cmd "bitable-check remote" @("go", "run", ".", "feishu", "+bitable-check", "--tables", "reports,issues,prs,contributors,tasks", "--remote", "--format", "table") $false
} else {
Add-Skip "bitable diagnostics" "missing Feishu app credentials, base app token, or required table IDs"
}
if (Has-Env @("FEISHU_APP_ID", "FEISHU_APP_SECRET") -and (Has-Env @("FEISHU_WIKI_URL") -or Has-Env @("FEISHU_WIKI_NODE_TOKEN") -or Has-Env @("FEISHU_FOLDER_TOKEN") -or Has-Env @("FEISHU_DOCUMENT_ID"))) {
Invoke-Cmd "doc-export send" @("go", "run", ".", "feishu", "+doc-export", "--from-workflow-json", $ReportJSON, "--send", "--format", "table") $false
} else {
Add-Skip "doc-export send" "missing FEISHU_APP_ID/FEISHU_APP_SECRET or DocX/Wiki target"

View File

@ -0,0 +1,394 @@
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), " ")
}

View File

@ -26,6 +26,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
newWeeklyReportShortcut(),
newOwnerDigestShortcut(),
newContributorDigestShortcut(),
newAppCheckShortcut(),
newDocCheckShortcut(),
newBitableCheckShortcut(),
newTaskCheckShortcut(),
newDocExportShortcut(),
newBitableSchemaShortcut(),
newBitableRecordsShortcut(),
@ -35,6 +39,73 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
}
}
func newAppCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "app-check",
Description: "Check Feishu custom bot and self-built app configuration without writing resources",
Flags: []common.Flag{
{Name: "webhook-url", Usage: "Feishu custom bot webhook URL. Defaults to FEISHU_WEBHOOK_URL"},
{Name: "secret", Usage: "Feishu custom bot signing secret. Defaults to FEISHU_WEBHOOK_SECRET"},
{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: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runAppCheck,
}
}
func newDocCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "doc-check",
Description: "Check Feishu DocX/Wiki export configuration without writing documents",
Flags: []common.Flag{
{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: "folder-token", Usage: "Feishu folder token for creating a new DocX. Defaults to FEISHU_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: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runDocCheck,
}
}
func newBitableCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bitable-check",
Description: "Check Feishu Base/Bitable sync configuration and table readiness without writing records",
Flags: []common.Flag{
{Name: "tables", Usage: "Comma-separated tables: reports,issues,prs,contributors,tasks", Default: defaultTables},
{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: "base-app-token", Usage: "Feishu Base app token. Defaults to FEISHU_BASE_APP_TOKEN"},
{Name: "report-table-id", Usage: "Reports table ID. Defaults to FEISHU_REPORT_TABLE_ID"},
{Name: "issue-table-id", Usage: "Issues table ID. Defaults to FEISHU_ISSUE_TABLE_ID"},
{Name: "pr-table-id", Usage: "Pull requests table ID. Defaults to FEISHU_PR_TABLE_ID"},
{Name: "contributor-table-id", Usage: "Contributors table ID. Defaults to FEISHU_CONTRIBUTOR_TABLE_ID"},
{Name: "task-table-id", Usage: "Tasks table ID. Defaults to FEISHU_TASK_TABLE_ID"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runBitableCheck,
}
}
func newTaskCheckShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "task-check",
Description: "Check Feishu Task configuration without creating tasks",
Flags: []common.Flag{
{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: "task-project-id", Usage: "Feishu task project ID. Defaults to FEISHU_TASK_PROJECT_ID"},
{Name: "task-section-id", Usage: "Feishu task section ID. Defaults to FEISHU_TASK_SECTION_ID"},
{Name: "remote", Usage: "Call Feishu OpenAPI read/check endpoints. No resources are written", Bool: true, Default: "false"},
},
Run: runTaskCheck,
}
}
func newBotTestShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "bot-test",
@ -299,6 +370,22 @@ func runContributorDigest(ctx *common.RuntimeContext) error {
return renderDigest(os.Stdout, digest, formatOrDefault(ctx, "markdown"), normalizeLang(ctx.Arg("lang")))
}
func runAppCheck(ctx *common.RuntimeContext) error {
return runFeishuAppCheck(ctx)
}
func runDocCheck(ctx *common.RuntimeContext) error {
return runFeishuDocCheck(ctx)
}
func runBitableCheck(ctx *common.RuntimeContext) error {
return runFeishuBitableCheck(ctx)
}
func runTaskCheck(ctx *common.RuntimeContext) error {
return runFeishuTaskCheck(ctx)
}
func runDocExport(ctx *common.RuntimeContext) error {
opts, err := docExportOptionsFromContext(ctx)
if err != nil {

View File

@ -20,7 +20,7 @@ func TestShortcutsExposeExpectedCommands(t *testing.T) {
for _, shortcut := range Shortcuts() {
got[shortcut.Name] = true
}
for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} {
for _, name := range []string{"bot-test", "notify", "weekly-report", "owner-digest", "contributor-digest", "app-check", "doc-check", "bitable-check", "task-check", "doc-export", "bitable-schema", "bitable-records", "bitable-sync", "task-preview", "task-create"} {
if !got[name] {
t.Fatalf("Shortcuts missing %s", name)
}
@ -316,6 +316,103 @@ func TestBitableSyncMockHTTP(t *testing.T) {
}
}
func TestAppCheckRemoteMockHTTP(t *testing.T) {
var sawToken bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost && r.URL.Path == "/auth/v3/tenant_access_token/internal" {
sawToken = true
_, _ = w.Write([]byte(`{"code":0,"msg":"success","tenant_access_token":"tenant-token","expire":7200}`))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"webhook-url": "https://open.feishu.cn/open-apis/bot/v2/hook/test",
"remote": "true",
}}
if err := runFeishuAppCheck(ctx); err != nil {
t.Fatalf("runFeishuAppCheck returned error: %v", err)
}
if !sawToken {
t.Fatal("expected tenant token request")
}
}
func TestBitableCheckRemoteUsesSearchOnly(t *testing.T) {
var sawSearch 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.MethodPost && r.URL.Path == "/bitable/v1/apps/base_token/tables/tbl_report/records/search":
sawSearch = true
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"items":[]}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"base-app-token": "base_token",
"tables": "reports",
"report-table-id": "tbl_report",
"remote": "true",
}}
if err := runFeishuBitableCheck(ctx); err != nil {
t.Fatalf("runFeishuBitableCheck returned error: %v", err)
}
if !sawSearch {
t.Fatal("expected bitable search request")
}
}
func TestTaskCheckRemoteDoesNotCreateTask(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Content-Type", "application/json")
if 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}`))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
oldBaseURL := openAPIBaseURL
openAPIBaseURL = server.URL
defer func() { openAPIBaseURL = oldBaseURL }()
ctx := &common.RuntimeContext{Args: map[string]string{
"app-id": "cli_xxx",
"app-secret": "secret",
"remote": "true",
}}
if err := runFeishuTaskCheck(ctx); err != nil {
t.Fatalf("runFeishuTaskCheck returned error: %v", err)
}
if requestCount != 1 {
t.Fatalf("expected only tenant token request, got %d requests", requestCount)
}
}
func TestTaskCreateOptionsRejectSendDryRun(t *testing.T) {
ctx := &common.RuntimeContext{Args: map[string]string{
"send": "true",