Compare commits

...

4 Commits

Author SHA1 Message Date
liangshuo-1
13411d9a51 chore(release): v1.0.34 (#972)
Change-Id: I0908c20f6ab9cf76a5d75cc1c81871591aa6a841
2026-05-19 20:03:56 +08:00
search_zhuhao
939b7b6fb6 docs(lark-vc): clarify meeting search evidence flow (#866)
* docs(lark-vc): clarify meeting search evidence flow

Change-Id: I997ec0654b9448eb0cc6ed7c15493dd2316ffa39

* docs(lark-vc): clarify pagination precedence

Change-Id: Icdcc38db2ce3db3a3371c6451624fd52a71170e3
2026-05-19 19:41:12 +08:00
SunPeiYang996
a4c5ec99c8 docs(drive): clarify add comment constraints (#967)
Change-Id: I637cfaf2d6a228c43e3b3041fef8e030bc80b9d0
2026-05-19 18:09:28 +08:00
fangshuyu-768
7c54f9b023 feat(drive): switch markdown export to V2 docs_ai fetch API (#948)
Switch `drive +export --file-extension markdown` from the legacy V1
GET /open-apis/docs/v1/content API to the V2
POST /open-apis/docs_ai/v1/documents/{token}/fetch API for
higher-quality Lark-flavored Markdown output.

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
2026-05-19 17:53:54 +08:00
8 changed files with 238 additions and 40 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.34] - 2026-05-19
### Features
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
- **base**: Support Base attachment APIs (#887)
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
### Bug Fixes
- **identitydiag**: Harden verify path and tighten status semantics (#961)
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
- **auth**: Split bot and user identity diagnostics (#957)
- **base**: Address Base attachment review follow-ups (#958)
- **docs**: Clarify `replace_all` selection errors (#954)
### Documentation
- **drive**: Clarify add comment constraints (#967)
- **lark-im**: Clarify message activity search (#865)
### Tests
- Verify e2e resource cleanup (#949)
- **lint**: Exclude `bidichk` from test files (#959)
## [v1.0.33] - 2026-05-18
### Features
@@ -745,6 +774,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.33",
"version": "1.0.34",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
@@ -101,23 +102,33 @@ var DriveExport = common.Shortcut{
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk.
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI(
"GET",
"/open-apis/docs/v1/content",
map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
},
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.DoAPIJSONWithLogID(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len([]byte(common.GetString(data, "content"))),
"size_bytes": len(content),
}, nil)
return nil
}

View File

@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# hello\n",
"document": map[string]interface{}{
"content": "# hello\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
"document": map[string]interface{}{
"content": "# custom\n",
},
},
},
})
}
reg.Register(fetchStub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
"document": map[string]interface{}{
"content": "# fallback\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
}
}
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document object, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
}
}
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"document": map[string]interface{}{},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document.content, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -150,8 +150,6 @@ lark-cli drive +add-comment \
- `type=text` 的评论文本不能直接包含 `<``>`;应优先传 `&lt;``&gt;`。shortcut 在发送前也会自动将 `<``>` 转义为 `&lt;``&gt;` 作为兜底。
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`
-`locate-doc` 命中多处时shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
- 统一接口:`POST /new_comments`
- 统一字段:`file_type` + `reply_elements`

View File

@@ -25,8 +25,8 @@ lark-cli drive +export \
--doc-type doc \
--file-extension docx
# 导出 docx 为 markdown
# 注意markdown 只支持 docx,底层走 /open-apis/docs/v1/content
# 导出 docx 为 markdownLark-flavored Markdown
# 注意markdown 只支持 docx
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
--doc-type docx \

View File

@@ -7,6 +7,12 @@
本 skill 对应 shortcut`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。
## 关键词使用边界
`--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes``vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
## 典型触发表达
以下说法通常应优先使用 `vc +search`
@@ -42,6 +48,12 @@ lark-cli vc +search --organizer-ids "ou_a,ou_b"
# 按参与者过滤open_id逗号分隔
lark-cli vc +search --participant-ids "ou_x,ou_y"
# 查询我这个月参加过的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --participant-ids "ou_me"
# 查询最近两周我组织的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --organizer-ids "ou_me"
# 按会议室过滤
lark-cli vc +search --room-ids "123,456"
@@ -76,6 +88,10 @@ lark-cli vc +search --query "周会" --format json
所有参数均可选,但必须至少提供一个过滤条件:`--query``--start``--end``--organizer-ids``--participant-ids``--room-ids`
没有真实关键词时,时间范围或人员过滤已经满足这个约束,`--query` 可以省略。
涉及"本月"、"最近两周"这类相对时间时,先基于执行当天计算 `"<YYYY-MM-DD>"` 占位符,再运行命令;不要沿用文档示例生成时的具体日期。
### 2. 仅搜索历史会议
`vc +search` 只能搜索已结束的历史会议记录,不用于查询未来日程。查询未来会议安排请使用 [lark-calendar](../../lark-calendar/SKILL.md)。
@@ -128,7 +144,8 @@ lark-cli vc +search --query "周会" --format json
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more``page_token` 为准。
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
- 未明确要求全量时,`total` 数量小于 50 自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否继续获取全部结果。
- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 `total > 50` 的确认门槛;直接完成分页和去重,再排序或统计,不要只用第一页回答。
```bash
# First page

View File

@@ -60,3 +60,42 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "docxMdDryRun",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "my-notes",
"--output-dir", "./md-exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}