Compare commits

...

7 Commits

Author SHA1 Message Date
zchao.007
2c906b9415 feat(drive): add +copy shortcut
Wrap the Drive file-copy endpoint as drive +copy. Accept a document URL
(recommended) or bare token + --type for the source; the target takes a
folder token, a folder URL, or the my_space constant, which resolves the
caller's My Space root folder via the root-folder-meta endpoint (absent
from platform metadata, works for both user and bot). Repeatable --extra
key=value pairs are forwarded verbatim for special copy semantics (e.g.
target_type=docx to convert a legacy doc during copy). Reject wiki
URLs/tokens with a typed validation error whose hint carries a
ready-to-adapt wiki +node-copy command, because a Drive copy of a
wiki-backed document would land in Drive space instead of the wiki tree.

Declare docs:document:copy (the narrowest scope in the endpoint's any-of
set) plus a conditional drive:drive.metadata:readonly for my_space
resolution. Cover the shortcut with unit tests, dry-run e2e and a
self-contained live workflow (upload -> copy -> download-verify ->
my_space copy -> cleanup), and register it in
tests/cli_e2e/drive/coverage.md. Route copy intents in the lark-drive
skill to the shortcut instead of the raw files copy service command.
2026-07-31 17:39:45 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
70 changed files with 12458 additions and 423 deletions

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
Risk: "read",
Scopes: []string{},
ConditionalScopes: []string{
"base:block:read",
"base:field:read",
"base:record:read",
"wiki:node:retrieve",
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
return common.NewDryRunAPI().Set("error", err.Error())
}
switch classifyBaseURL(parsed) {
case "base_url":
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(map[string]interface{}{}).
Set("base_token", baseToken).
Set("selected_block_id", selectedBlockID)
}
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
case "wiki_url":
return common.NewDryRunAPI().
GET("/open-apis/wiki/v2/spaces/get_node").
dry := common.NewDryRunAPI()
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
if selectedBlockID == "" {
return dry.
GET("/open-apis/wiki/v2/spaces/get_node").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
}
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve the Wiki node to its underlying Base").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Desc("[2] List Base blocks and match selected_block_id").
Body(map[string]interface{}{})
return dry.
Set("base_token", "<obj_token from step 1>").
Set("selected_block_id", selectedBlockID)
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out)
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
selection := resolveBaseURLSelection(parsed)
applyBaseURLSelection(out, selection)
enrichBaseResolveHint(runtime, out, selection)
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
}
func resolveBaseURL(u *url.URL) map[string]interface{} {
query := u.Query()
out := map[string]interface{}{
"input_type": "base_url",
"resource_type": "bitable",
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
}
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
out["table_id"] = tableID
}
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
out["view_id"] = viewID
}
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
out["record_id"] = recordID
}
applyBaseURLSelection(out, resolveBaseURLSelection(u))
return out
}
type baseURLSelection struct {
blockID string
viewID string
recordID string
}
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
query := u.Query()
return baseURLSelection{
blockID: strings.TrimSpace(query.Get("table")),
viewID: strings.TrimSpace(query.Get("view")),
recordID: strings.TrimSpace(query.Get("record")),
}
}
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.blockID != "" {
// The Base web UI historically uses the query key "table" for the
// currently selected top-level block. Its value can identify a table,
// dashboard, workflow, or another block type. Keep it neutral until the
// block directory confirms the resource type.
out["block_id"] = selection.blockID
out["selection_source"] = "url_query"
}
}
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.viewID != "" {
out["view_id"] = selection.viewID
}
if selection.recordID != "" {
out["record_id"] = selection.recordID
}
}
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
if baseToken == "" || tableID == "" {
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
if baseToken == "" || selectedBlockID == "" {
out["hint"] = resolveHint("", nil)
return
}
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
out["block_type"] = block.Type
if block.Name != "" {
out["block_name"] = block.Name
}
switch block.Type {
case "table":
applyResolvedTableSelection(out, selection)
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
case "dashboard":
out["dashboard_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
}
case "workflow":
out["workflow_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "use +workflow-get to inspect the resolved workflow",
}
case "folder":
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
}
case "docx":
if block.DocxToken != "" {
out["docx_token"] = block.DocxToken
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
}
} else {
out["hint"] = map[string]interface{}{
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
}
}
default:
out["hint"] = resolveUnknownBlockHint()
}
return
}
out["hint"] = resolveUnknownBlockHint()
}
type resolvedBaseBlock struct {
ID string
Type string
Name string
DocxToken string
}
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
if err != nil {
return resolvedBaseBlock{}, false, err
}
for _, item := range common.GetSlice(data, "blocks") {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
block := resolvedBaseBlock{
ID: strings.TrimSpace(common.GetString(row, "id")),
Type: strings.TrimSpace(common.GetString(row, "type")),
Name: strings.TrimSpace(common.GetString(row, "name")),
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
}
if block.ID == selectedBlockID {
return block, true, nil
}
}
return resolvedBaseBlock{}, false, nil
}
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
out["table_id"] = tableID
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
}
func resolveUnknownBlockHint() map[string]interface{} {
return map[string]interface{}{
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
}
}
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))

View File

@@ -4,6 +4,7 @@
package base
import (
"net/http"
"strings"
"testing"
@@ -17,6 +18,9 @@ import (
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
))
fieldStub := fieldListStub("bas123", "tbl123")
fieldStub.Optional = true
fieldStub.OnMatch = func(_ *http.Request) {
t.Fatalf("field endpoint must not be used to infer selected block type")
}
reg.Register(fieldStub)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "tbl123" {
t.Fatalf("unexpected block coordinates: %#v", data)
}
if _, ok := data["block_type"]; ok {
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
}
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("dashboard selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
t.Fatalf("unexpected dashboard coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
t.Fatalf("unexpected dashboard hint: %#v", hint)
}
})
t.Run("workflow selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
t.Fatalf("unexpected workflow coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("workflow must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
t.Fatalf("unexpected workflow hint: %#v", hint)
}
})
t.Run("folder selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
t.Fatalf("unexpected folder coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("folder must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected folder hint: %#v", hint)
}
})
t.Run("docx selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
t.Fatalf("unexpected docx coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("docx must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected docx hint: %#v", hint)
}
})
}
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
items := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
items = append(items, block)
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": items,
"total": len(items),
},
},
}
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
}
})
t.Run("bitable with table coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
}
})
t.Run("bitable with dashboard selection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
})
}
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": baseToken,
"title": title,
},
},
},
}
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const botSearchURL = "/open-apis/bot/v4/bot/search"
const (
maxBotSearchQueryChars = 50
maxBotSearchChatIDs = 100
maxBotSearchPageSize = 30
)
type botSearchAPIRequest struct {
Query string `json:"query,omitempty"`
Filter *botSearchAPIFilter `json:"filter,omitempty"`
}
// HasChatter uses omitempty: validation rejects =false, so a set field is always
// true and an unset field stays out of the request entirely.
type botSearchAPIFilter struct {
ChatIDs []string `json:"chat_ids,omitempty"`
HasChatter bool `json:"has_chatter,omitempty"`
}
type botSearchAPIData struct {
Items []botSearchAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type botSearchAPIItem struct {
ID string `json:"id"`
DisplayInfo string `json:"display_info"`
MetaData botSearchAPIMeta `json:"meta_data"`
}
type botSearchAPIMeta struct {
TenantID string `json:"tenant_id"`
EnableJoinGroup bool `json:"enable_join_group"`
ChatID string `json:"chat_id"`
IsAgent bool `json:"is_agent"`
}
type searchBot struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// ChatID is the caller's P2P chat with the bot.
ChatID string `json:"chat_id"`
EnableJoinGroup bool `json:"enable_join_group"`
IsAgent bool `json:"is_agent"`
TenantID string `json:"tenant_id,omitempty"`
MatchSegments []string `json:"match_segments"`
}
// PageToken is decoded from the response but deliberately not surfaced, matching
// searchUserResponse: neither search command paginates. Callers narrow the query
// instead, so handing out a token that no flag accepts would only mislead.
type searchBotResponse struct {
Bots []searchBot `json:"bots"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchBot = common.Shortcut{
Service: "contact",
Command: "+search-bot",
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
Risk: "read",
Scopes: []string{"search:bot"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotSearch(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range parseAndDedupQueries(raw) {
body := &botSearchAPIRequest{Query: q, Filter: filter}
api.POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildBotSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
},
Execute: executeBotSearch,
}
// executeBotSearch dispatches to single-query or fanout mode.
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeBotSearchFanout(ctx, runtime)
}
return executeBotSearchSingle(ctx, runtime)
}
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
// requirement. Naming only --query would tell an agent that --queries is not a
// way out, which it is.
func botSearchKeywordRequiredError() error {
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
)
}
// botSearchHasChattedFalseError is raised from two places — with and without a
// keyword — so the wording stays in one spot.
//
// Agents passing =false almost always mean "do not filter", but the API reads it
// as "must NOT match". A hard error prevents silent wrong results.
func botSearchHasChattedFalseError() error {
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
WithParam("--has-chatted")
}
func validateBotSearch(runtime *common.RuntimeContext) error {
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
query := strings.TrimSpace(runtime.Str("query"))
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
if queriesRaw != "" {
if query != "" {
return common.ValidationErrorf("--query and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
)
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
}
if len(queries) > maxFanoutQueries {
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
WithParam("--queries")
}
}
} else if query == "" {
// No keyword at all. An explicit =false is the more specific mistake, so
// report it instead of sending the caller off to add a keyword only to hit
// this on the next attempt. +search-user lands here too: a Changed bool
// counts as search input for its "at least one" gate, so the =false check
// is what it reaches next.
//
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
// checks would let it mask the mutual-exclusion and length errors, which
// +search-user reports first when a keyword is present.
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
return botSearchKeywordRequiredError()
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
WithParam("--query")
}
if _, err := parseBotSearchChatIDs(runtime); err != nil {
return err
}
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
WithParam("--page-size")
}
return nil
}
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
raw := strings.TrimSpace(runtime.Str("chat-ids"))
if raw == "" {
return nil, nil
}
parts := common.SplitCSV(raw)
if len(parts) == 0 {
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
WithParam("--chat-ids")
}
// Normalize before deduping, then check the cap against the deduped list —
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
// way would spend the server's 100-entry budget on duplicates, and would let
// 101 copies of one chat be rejected here while the sibling command accepts
// them. Normalization matters too: a chat URL and a bare chat_id can name the
// same chat.
seen := make(map[string]struct{}, len(parts))
chatIDs := make([]string, 0, len(parts))
for _, part := range parts {
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
if err != nil {
return nil, err
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
chatIDs = append(chatIDs, normalized)
}
if len(chatIDs) > maxBotSearchChatIDs {
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
WithParam("--chat-ids")
}
return chatIDs, nil
}
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
// A nil filter means "no scope": an empty filter object is not the same request.
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
filter := &botSearchAPIFilter{}
hasFilter := false
chatIDs, err := parseBotSearchChatIDs(runtime)
if err != nil {
return nil, err
}
if len(chatIDs) > 0 {
filter.ChatIDs = chatIDs
hasFilter = true
}
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
filter.HasChatter = true
hasFilter = true
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return nil, err
}
return &botSearchAPIRequest{
Query: strings.TrimSpace(runtime.Str("query")),
Filter: filter,
}, nil
}
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
// response envelope — notice, has_more, and in fanout mode queries[] — into
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
// every piece of "this result is not the whole answer" metadata would vanish and
// the caller would read a truncated result as a complete one. For those formats
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
// can still project it away, but that is the caller's explicit choice.
func botSearchStdoutCarriesEnvelope(format string) bool {
return format == "json" || format == ""
}
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildBotSearchBody(runtime)
if err != nil {
return err
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return err
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return err
}
bots := projectBots(respData)
out := searchBotResponse{
Bots: bots,
HasMore: respData.HasMore,
Notice: respData.Notice,
}
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
if len(bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotRows(bots))
})
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
}
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintln(runtime.IO().ErrOut,
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
}
return nil
}
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
}
var out botSearchAPIData
if err := json.Unmarshal(raw, &out); err != nil {
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
}
return &out, nil
}
func projectBots(data *botSearchAPIData) []searchBot {
if data == nil {
return []searchBot{}
}
bots := make([]searchBot, 0, len(data.Items))
for i := range data.Items {
item := &data.Items[i]
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
bots = append(bots, searchBot{
OpenID: item.ID,
Name: name,
Description: description,
ChatID: item.MetaData.ChatID,
EnableJoinGroup: item.MetaData.EnableJoinGroup,
IsAgent: item.MetaData.IsAgent,
TenantID: item.MetaData.TenantID,
MatchSegments: segments,
})
}
return bots
}
func stripHighlightTags(value string) string {
value = strings.ReplaceAll(value, "<h>", "")
return strings.ReplaceAll(value, "</h>", "")
}
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
matchSegments = make([]string, 0)
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
// The capture can still carry a tag: the non-greedy pattern pairs a
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
// name and description it came from, and drop a highlight with no text.
segment := html.UnescapeString(stripHighlightTags(match[1]))
if strings.TrimSpace(segment) == "" {
continue
}
matchSegments = append(matchSegments, segment)
}
lines := strings.Split(raw, "\n")
stripTags := func(value string) string {
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
}
// nameLine records which line the name came from, so the description is read
// from the line after it. Reading lines[1] unconditionally echoes the name
// back as its own description whenever line 0 is blank, and drops the real
// description with it.
nameLine := -1
if len(lines) > 0 {
if candidate := stripTags(lines[0]); candidate != "" {
name = candidate
nameLine = 0
}
}
if name == "" {
for i, line := range lines {
if candidate := stripTags(line); candidate != "" {
name = candidate
nameLine = i
break
}
}
}
if nameLine >= 0 && nameLine+1 < len(lines) {
description = stripTags(lines[nameLine+1])
}
return name, description, matchSegments
}
// map[] shape is required by output.PrintTable.
func prettyBotRows(bots []searchBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,289 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
// response summary types.
type botFanoutResult struct {
Index int
Query string
Bots []searchBot
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed propagation
}
// runOneBotQuery converts one fanout request into either bots or an error summary.
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *botSearchAPIFilter) botFanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a request;
// in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return botFanoutErrorResult(index, query, err)
}
body := &botSearchAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return botFanoutErrorResult(index, query, err)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
return botFanoutResult{
Index: index,
Query: query,
Bots: projectBots(respData),
HasMore: respData.HasMore,
Notice: respData.Notice,
}
}
// botFanoutErrorResult records a failed fanout query without stopping other workers.
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
if err == nil {
return botFanoutResult{Index: index, Query: query}
}
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
}
func botFanoutContextError(err error) error {
subtype := errs.SubtypeNetworkTransport
message := "bot search fanout cancelled"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "bot search fanout deadline exceeded"
}
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
}
func botFanoutPanicError(query string, recovered any) error {
err := errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q panicked: %v", query, recovered)
if cause, ok := recovered.(error); ok {
return err.WithCause(cause)
}
return err
}
// Terminal failures invalidate the batch; API and network failures remain
// eligible for partial-success reporting.
func botFanoutTerminalError(results []botFanoutResult) error {
for _, result := range results {
if result.Err == nil {
continue
}
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
return botFanoutContextError(result.Err)
}
problem, ok := errs.ProblemOf(result.Err)
if !ok {
return errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
WithCause(result.Err)
}
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
return result.Err
}
}
return nil
}
type fanoutBot struct {
searchBot
MatchedQuery string `json:"matched_query"`
}
type botFanoutResponse struct {
Bots []fanoutBot `json:"bots"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
// errors fail the batch even when another query succeeded.
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
if err := botFanoutTerminalError(results); err != nil {
return nil, err
}
indexed := make([]botFanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &botFanoutResponse{
Bots: make([]fanoutBot, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErr error
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErr = r.Err
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, b := range r.Bots {
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
return nil, contactFanoutAllFailedError(firstErr, msg)
}
return out, nil
}
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return err
}
results := make([]botFanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
schedule:
for i, q := range queries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
for j := i; j < len(queries); j++ {
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
}
break schedule
}
wg.Add(1)
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
err := botFanoutPanicError(q, r)
results[i] = botFanoutResult{
Index: i,
Query: q,
ErrMsg: contactFanoutErrorSummary(err),
Err: err,
}
}
}()
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildBotFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
if len(resp.Bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
len(queries), len(resp.Bots), failed, hasMoreCount)
}
// The counts above say how many queries failed but not which, and only the
// json envelope carries queries[].error / queries[].notice. Without this an
// agent reading csv or a table sees "1 failed" with no way to learn the
// keyword or the reason, and a notice disappears entirely.
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
for _, qs := range resp.Queries {
if qs.Error != "" {
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
}
if qs.Notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
}
if qs.HasMore {
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
}
}
}
return nil
}
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"matched_query": bot.MatchedQuery,
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,684 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
r := botFanoutErrorResult(3, "会议助手", nil)
if r.ErrMsg != "" || r.Err != nil {
t.Fatalf("nil error must stay a success result: %+v", r)
}
if r.Index != 3 || r.Query != "会议助手" {
t.Fatalf("index/query must survive: %+v", r)
}
}
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
results := []botFanoutResult{
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Results are emitted in query order even though the workers finished out of
// order, and a failed query contributes no rows.
wantRows := []struct {
openID, matched string
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
if len(resp.Bots) != len(wantRows) {
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
}
for i, w := range wantRows {
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
}
}
want := []querySummary{
{Query: "会议"},
{Query: "日报", HasMore: true},
{Query: "审批", Error: "API 1: nope"},
}
if len(resp.Queries) != len(want) {
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("expected an error when every query fails")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's classification must survive, so the caller can tell a
// rate limit apart from a transport fault.
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
}
// Agents grep the count and the first failure out of this message.
for _, want := range []string{"all 2 queries failed", "rate limit"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err != nil {
t.Fatalf("one failure out of two must not fail the call: %v", err)
}
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
t.Fatalf("partial failure shape: %+v", resp)
}
}
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
tests := []struct {
name string
err error
wantSubtype errs.Subtype
}{
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
botFanoutErrorResult(1, "日报", tt.err),
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("terminal context error must fail the batch after a partial success")
}
if !errors.Is(err, tt.err) {
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
}
})
}
}
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// has_more is per query in the sidecar; a single top-level flag would hide
// which keyword was truncated.
if _, ok := envelope["has_more"]; ok {
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
}
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
t.Fatalf("per-query has_more lost: %s", raw)
}
}
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"bots":[]`) {
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
}
}
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
rows := prettyBotFanoutRows([]fanoutBot{{
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
MatchedQuery: "会议",
}})
if len(rows) != 1 {
t.Fatalf("rows: %d", len(rows))
}
if rows[0]["matched_query"] != "会议" {
t.Errorf("matched_query missing: %+v", rows[0])
}
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
}
}
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", "会议")
setBotSearchFlag(t, cmd, "queries", "会议,日报")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if err == nil {
t.Fatal("expected mutual-exclusion error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("message: %v", err)
}
}
func TestBotFanoutValidationLimits(t *testing.T) {
tests := []struct {
name string
queries string
wantParam string
}{
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
queries := tt.queries
if strings.Contains(queries, "%d") {
parts := make([]string, 0, maxFanoutQueries+1)
for i := 0; i <= maxFanoutQueries; i++ {
parts = append(parts, fmt.Sprintf("q%d", i))
}
queries = strings.Join(parts, ",")
}
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", queries)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
})
}
}
// --queries alone is enough: the single-search "--query is required" rule must not
// leak into fanout mode.
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) != 2 {
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
}
seen := make(map[string]bool, len(stub.CapturedBodies))
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
seen[fmt.Sprint(body["query"])] = true
filter, ok := body["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true {
t.Fatalf("filter must ride along with every query: %#v", body)
}
}
for _, q := range []string{"会议", "日报"} {
if !seen[q] {
t.Fatalf("query %q never issued; saw %v", q, seen)
}
}
}
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
dedupStub.Reusable = true
registry.Register(dedupStub)
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
// requests or the rows.
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
}
for _, bot := range envelope.Data.Bots {
if bot.MatchedQuery != "会议" {
t.Fatalf("matched_query fidelity: %+v", bot)
}
}
}
func TestBotFanoutConcurrencyCap(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
var inFlight, peak int32
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
}
registry.Register(stub)
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > fanoutConcurrency {
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
}
}
func TestBotFanoutPanicFailsBatch(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
panicCause := errors.New("synthetic test panic")
boom := botSearchStub(botSearchURL, "")
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
registry.Register(boom)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a panicking query must fail the batch")
}
if !errors.Is(err, panicCause) {
t.Fatalf("panic cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
}
if stdout.Len() != 0 {
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
}
}
}
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL,
Reusable: true,
Status: 500,
Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("every query failing must surface as a command error")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's upstream status and the all-failed mode must both survive,
// so a caller can classify instead of seeing a generic internal error.
for _, want := range []string{"500", "all 2 queries failed"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
// Assert the notice itself, not just that some row survived: the surviving
// query's server remark has to reach the caller both at the top level and in
// its own sidecar entry.
if envelope.Data.Notice != wantNotice {
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
}
if len(envelope.Data.Queries) != 2 {
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
}
if envelope.Data.Queries[0].Notice != wantNotice {
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
}
if envelope.Data.Queries[0].Error != "" {
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
}
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
}
// Only the surviving query contributes rows.
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
}
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
}
// csv is in the summary format set, so the batch counters belong on stderr.
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
}
if strings.Contains(stderr.String(), "total bots") {
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
}
}
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
// ndjson is a machine format outside the summary set: every stdout line must
// parse, and the counters must not be mixed in.
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
if line == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatalf("stdout line %d is not JSON: %q", i, line)
}
}
if strings.Contains(stderr.String(), "queries,") {
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
}
}
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
// the cancellation. Queueing more keywords than fanoutConcurrency while every
// worker is parked keeps all semaphore slots held, so the queued keywords can
// only leave the loop through its ctx.Done() branch.
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
var once sync.Once
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(*http.Request) {
once.Do(func() { close(started) })
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
}
registry.Register(stub)
go func() {
select {
case <-started:
case <-time.After(5 * time.Second): // never leave the workers parked
}
cancel()
}()
queries := make([]string, 0, fanoutConcurrency+3)
for i := 0; i < fanoutConcurrency+3; i++ {
queries = append(queries, fmt.Sprintf("q%d", i))
}
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a cancelled batch must surface as a command error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
// a queued worker must fail on the pre-check instead of issuing its request.
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
results := make([]botFanoutResult, 0, 2)
ctx, cancel := context.WithCancel(context.Background())
cancel()
for i, q := range []string{"会议", "日报"} {
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
}
for _, r := range results {
if r.ErrMsg == "" {
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
}
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("all queries cancelled must surface as an error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
setBotSearchFlag(t, cmd, "has-chatted", "true")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var preview struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body struct {
Query string `json:"query"`
Filter *struct {
ChatIDs []string `json:"chat_ids"`
HasChatter bool `json:"has_chatter"`
} `json:"filter"`
} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(raw, &preview); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, raw)
}
// Deduped, so the repeated keyword previews once — the preview has to match
// the requests Execute would actually issue.
if len(preview.API) != 2 {
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
}
seen := make([]string, 0, len(preview.API))
for i, call := range preview.API {
if call.Method != "POST" || call.URL != botSearchURL {
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
}
if call.Params["page_size"] != float64(20) {
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
}
if _, ok := call.Params["page_token"]; ok {
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
}
// The filter rides along with every keyword, not just the first.
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
}
seen = append(seen, call.Body.Query)
}
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
}
}
// The summary counts how many queries failed but never says which or why, and
// only json carries queries[].error. Without a per-query line on stderr an agent
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
for _, want := range []string{"日报", "500"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
format, want, stderr.String())
}
}
})
}
}

View File

@@ -0,0 +1,724 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newBotSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("chat-ids", "", "")
cmd.Flags().Bool("has-chatted", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("queries", "", "")
return cmd
}
func botSearchDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
UserOpenId: "ou_self",
}
}
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
t.Helper()
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if validationErr.Param != wantParam {
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
}
}
// assertBotSearchValidationParams covers the errors that name several flags via
// WithParams; those leave the single Param empty on purpose, so an agent reading
// the envelope sees every flag that could satisfy the requirement.
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
got := make([]string, 0, len(validationErr.Params))
for _, p := range validationErr.Params {
if p.Reason == "" {
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
}
got = append(got, p.Name)
}
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
t.Fatalf("params: got %v, want %v", got, wantParams)
}
}
func TestValidateBotSearchErrors(t *testing.T) {
chatIDs := make([]string, 101)
for i := range chatIDs {
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
}
tests := []struct {
name string
flags map[string]string
wantParam string
wantParams []string // set instead of wantParam when the error names several flags
wantMessage string
}{
{
name: "keyword missing",
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "query over 50 characters",
flags: map[string]string{"query": strings.Repeat("中", 51)},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
name: "chat ids parse empty",
flags: map[string]string{"query": "x", "chat-ids": " , , "},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
},
{
name: "over 100 chat ids",
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: must be at most 100 entries",
},
{
name: "invalid chat id",
flags: map[string]string{"query": "x", "chat-ids": "bad"},
wantParam: "--chat-ids",
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
},
{
// With a keyword present the keyword errors win, exactly as +search-user
// orders them; the =false check must not be hoisted above these.
name: "mutually exclusive keywords outrank has chatted false",
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
wantParams: []string{"--query", "--queries"},
wantMessage: "--query and --queries are mutually exclusive",
},
{
name: "query length outranks has chatted false",
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
// With no keyword at all the explicit =false is the more specific mistake,
// so it wins over the missing-keyword error rather than costing a second
// round trip. Matches which error +search-user reports first.
name: "has chatted false without a keyword",
flags: map[string]string{"has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "has chatted false",
flags: map[string]string{"query": "x", "has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "page size below one",
flags: map[string]string{"query": "x", "page-size": "0"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "page size over 30",
flags: map[string]string{"query": "x", "page-size": "31"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "chat ids without a keyword",
flags: map[string]string{"chat-ids": "oc_a"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "has chatted without a keyword",
flags: map[string]string{"has-chatted": "true"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if len(tt.wantParams) > 0 {
assertBotSearchValidationParams(t, err, tt.wantParams)
} else {
assertBotSearchValidationProblem(t, err, tt.wantParam)
}
if err.Error() != tt.wantMessage {
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestValidateBotSearchPassingCases(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "query only", flags: map[string]string{"query": "x"}},
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
// An explicitly blank string flag reads as "no filter", matching how
// +search-user treats --user-ids / --queries. Only a non-blank value that
// parses to zero entries is an error.
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
// Duplicates collapse before the cap is checked, so 101 copies of one chat
// is one entry — matching how --user-ids is resolved for +search-user.
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
for _, tt := range []struct {
name string
query string
wantError bool
}{
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
} {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", tt.query)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if tt.wantError {
assertBotSearchValidationProblem(t, err, "--query")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestBuildBotSearchBody(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantJSON string
}{
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
// A blank --chat-ids must not materialize an empty filter object.
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
// Deduped after normalization, so a repeated id and a URL naming the same
// chat both collapse into one entry instead of burning the server's quota.
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
body, err := buildBotSearchBody(runtime)
if err != nil {
t.Fatalf("build body: %v", err)
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
if string(raw) != tt.wantJSON {
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
}
})
}
}
func TestParseBotDisplayInfo(t *testing.T) {
tests := []struct {
name string
raw string
wantName string
wantDescription string
wantSegments []string
}{
// Whole name highlighted, description on line two.
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
// Two highlighted runs split by a plain character: stripping tags has to
// rejoin them into one name.
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
// Highlight at the end plus a trailing newline: line two exists but is empty.
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
// Single highlighted character in the middle of the name.
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
{name: "html entities", raw: "<h>Lark</h>部门成员&amp;仓库\n来自飞书&#22810;维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
{name: "html entity in highlight", raw: "名称<h>&amp;</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
{name: "empty", raw: "", wantSegments: []string{}},
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
// A blank first line must not make the description echo the name back and
// swallow the real description on the line after it.
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
// A highlight with no text carries nothing; an empty match segment is junk
// in the envelope. Which line the name comes from is left unchanged.
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
// capture can carry a tag the name and description already dropped.
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
// stripped before unescaping. Swapping that order eats the name's own text.
{name: "escaped angle brackets are name text", raw: "名称&lt;h&gt;工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
{name: "escaped angle brackets inside a highlight", raw: "<h>名称&lt;h&gt;</h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, description, segments := parseBotDisplayInfo(tt.raw)
if name != tt.wantName || description != tt.wantDescription {
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
}
if segments == nil {
t.Fatal("match segments must be an empty slice, not nil")
}
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
}
})
}
}
func TestProjectBotsMapsEveryField(t *testing.T) {
data := &botSearchAPIData{Items: []botSearchAPIItem{
{
ID: "ou_with_chat",
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
MetaData: botSearchAPIMeta{
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
},
},
{
ID: "ou_without_chat",
DisplayInfo: "",
MetaData: botSearchAPIMeta{TenantID: "1"},
},
}}
bots := projectBots(data)
if len(bots) != 2 {
t.Fatalf("bots: got %d, want 2", len(bots))
}
first := bots[0]
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
t.Fatalf("first bot mapping: %+v", first)
}
second := bots[1]
if second.Name != "" || second.ChatID != "" {
t.Fatalf("empty source fields must stay empty: %+v", second)
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if !strings.Contains(string(raw), `"chat_id":""`) {
t.Fatalf("empty chat_id must still be emitted: %s", raw)
}
if !strings.Contains(string(raw), `"name":""`) {
t.Fatalf("empty name must not fall back to open_id: %s", raw)
}
if strings.Contains(string(raw), `"has_chatted"`) {
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
}
}
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
if bots == nil {
t.Fatal("bots must be an empty slice, not nil")
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if string(raw) != `{"bots":[],"has_more":false}` {
t.Fatalf("response: got %s", raw)
}
}
func botSearchStub(url string, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": true,
"page_token": pageToken,
"items": []interface{}{
map[string]interface{}{
"id": "ou_bot",
"display_info": "<h>甲乙丙</h>\n一句话简介",
"meta_data": map[string]interface{}{
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
},
},
},
},
},
}
}
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
"--page-size", "25", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("request body: %v", err)
}
if requestBody["query"] != "甲乙" {
t.Fatalf("request query: got %v", requestBody["query"])
}
filter, ok := requestBody["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
t.Fatalf("request filter: %#v", requestBody["filter"])
}
var envelope struct {
Data searchBotResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
t.Fatalf("response pass-through: %+v", envelope.Data)
}
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
registry.Verify(t)
}
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
// The stub returns a token; the envelope must still not carry one, matching
// +search-user, which decodes page_token and drops it.
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v", err)
}
data := envelope["data"].(map[string]interface{})
if _, ok := data["page_token"]; ok {
t.Fatalf("page_token must never be surfaced: %v", data)
}
}
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
if !strings.Contains(stdout.String(), column) {
t.Errorf("pretty output missing %q: %s", column, stdout.String())
}
}
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
if strings.Contains(stdout.String(), genericField) {
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
}
}
// pretty stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
}
}
}
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("table output missing %q: %s", field, stdout.String())
}
}
// table stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
}
}
}
// The old name and assertion here pinned a bug: csv and ndjson were the two
// formats that carried neither has_more in stdout nor a hint on stderr, so a
// machine caller read a truncated result as the whole answer. stdout stays
// data-only; the truncation signal belongs on stderr for every format whose
// stdout has no envelope.
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
for _, format := range []string{"csv", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
}
}
// stdout must stay data-only, so both the notice and the truncation
// signal have to arrive on stderr.
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
}
}
if strings.Contains(stdout.String(), "more matches exist") {
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
}
})
}
}
func TestBotSearchPrettyEmptyResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL + "?page_size=20",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "No bots found.") {
t.Fatalf("pretty output: %q", stdout.String())
}
}
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
"--page-size", "25", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body botSearchAPIRequest `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("dry-run JSON: %v", err)
}
if len(envelope.Data.API) != 1 {
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
}
call := envelope.Data.API[0]
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
t.Fatalf("dry-run call: %+v", call)
}
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
t.Fatalf("dry-run body: %+v", call.Body)
}
}
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatal("expected marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem: %+v, ok=%v", problem, ok)
}
}
// Only the json envelope carries data.notice. If the other formats dropped it
// silently, a caller would read a truncated or incomplete result as a complete
// one, so every non-json format has to surface it on stderr instead.
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), notice) {
if format != "json" {
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
}
return
}
if !strings.Contains(stderr.String(), notice) {
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
// stdout stays pipe-clean: the notice must not be mixed into the rows.
if format == "csv" && strings.Contains(stdout.String(), "notice") {
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
}
})
}
}
// has_more is the server saying "this is not the whole answer". Only the json
// envelope carries it, so every other format has to say so on stderr or a machine
// caller silently treats a truncated result as complete.
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if format == "json" {
if !strings.Contains(stdout.String(), `"has_more": true`) {
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
}
return
}
if !strings.Contains(stderr.String(), "more matches exist") {
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
})
}
}

View File

@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
}
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "contact"}
s.Mount(parent, f)
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return parent.ExecuteContext(ctx)
}
// searchUserStub returns a representative user search response with a notice.

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ContactSearchUser,
ContactSearchBot,
ContactGetUser,
}
}

View File

@@ -47,6 +47,14 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// maxCommentReplyElements is the element-count cap declared ONLY by the
// reply-create endpoint (POST .../comments/:comment_id/replies), whose
// content.elements schema says "最大元素个数为100". It is enforced only by
// +add-reply. create_v2 (+add-comment) and the reply-update endpoint
// (+update-reply) do not declare this cap, so their inputs are not capped
// here — see the shared parseCommentReplyElements, which stays uncapped.
const maxCommentReplyElements = 100
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting

View File

@@ -918,6 +918,27 @@ func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
}
}
// create_v2 (+add-comment) uses reply_elements, which does NOT declare the
// 100-element cap that the reply-create endpoint does; +add-comment must not
// reject >100 elements locally.
func TestDriveAddCommentDoesNotCapElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/docx/docxToken",
"--content", "[" + strings.Join(elems, ",") + "]",
"--full-comment",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("+add-comment must not cap element count locally, got %v", err)
}
}
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveAddReplyOp = driveCommentOp{
Label: "comment reply",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveAddReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
}
func (s driveAddReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"content": map[string]interface{}{
"elements": driveReplyV1Elements(s.ReplyElements),
},
}
}
// DriveAddReply replies to an existing comment through the Drive comment
// reply create API (POST .../comments/:comment_id/replies), while accepting
// Wiki URLs/tokens and resolving them to the underlying object.
//
// Note: the documented alternative — POST .../comments with comment_id in the
// body ("如填写,则视为回复已有评论") — does NOT reply on docx in practice; it
// silently creates a new standalone comment instead.
var DriveAddReply = common.Shortcut{
Service: "drive",
Command: "+add-reply",
Description: "Add a reply to an existing comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:create"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveAddReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID to reply to (from drive +list-comments)", Required: true},
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
),
Tips: []string{
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"Whole-document comments (is_whole=true) and solved comments (is_solved=true) do not accept replies; check the comment state via `drive +list-comments` first.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveAddReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveAddReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveAddReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Adding reply to comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
data, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
)
if err != nil {
return err
}
extra := map[string]interface{}{
"comment_id": spec.CommentID,
"created": true,
}
if replyID := extractDriveCreatedReplyID(data); replyID != "" {
extra["reply_id"] = replyID
}
runtime.Out(driveCommentTargetOutput(target, extra), nil)
return nil
},
}
func readDriveAddReplySpec(runtime *common.RuntimeContext) (driveAddReplySpec, error) {
ref, err := resolveDriveCommentInput(driveAddReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveAddReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveAddReplySpec{}, err
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return driveAddReplySpec{}, err
}
// The reply-create endpoint documents a 100-element cap on content.elements;
// reject over-cap input locally instead of surfacing the opaque [1069302].
if len(replyElements) > maxCommentReplyElements {
return driveAddReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content has %d elements; the reply endpoint caps content.elements at %d", len(replyElements), maxCommentReplyElements).
WithParam("--content")
}
return driveAddReplySpec{
Ref: ref,
CommentID: commentID,
ReplyElements: replyElements,
}, nil
}
// driveReplyV1Elements converts the simplified +add-comment reply element form
// (text / mention_user / link) to the Drive v1 comment create wire form
// (text_run / person / docs_link).
func driveReplyV1Elements(replyElements []map[string]interface{}) []map[string]interface{} {
elements := make([]map[string]interface{}, 0, len(replyElements))
for _, element := range replyElements {
switch common.GetString(element, "type") {
case "text":
elements = append(elements, map[string]interface{}{
"type": "text_run",
"text_run": map[string]interface{}{"text": common.GetString(element, "text")},
})
case "mention_user":
elements = append(elements, map[string]interface{}{
"type": "person",
"person": map[string]interface{}{"user_id": common.GetString(element, "mention_user")},
})
case "link":
elements = append(elements, map[string]interface{}{
"type": "docs_link",
"docs_link": map[string]interface{}{"url": common.GetString(element, "link")},
})
}
}
return elements
}
// extractDriveCreatedReplyID pulls the created reply ID out of the reply
// create response, tolerating the shapes the API family uses: a top-level
// reply_id, a nested reply object, or a reply_list wrapper.
func extractDriveCreatedReplyID(data map[string]interface{}) string {
if replyID := common.GetString(data, "reply_id"); replyID != "" {
return replyID
}
if reply := common.GetMap(data, "reply"); reply != nil {
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
replyList := common.GetMap(data, "reply_list")
if replyList == nil {
return ""
}
for _, item := range common.GetSlice(replyList, "replies") {
reply, ok := item.(map[string]interface{})
if !ok {
continue
}
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
return ""
}
func buildDriveAddReplyDryRun(spec driveAddReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> add reply to comment").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
Desc("[2] Add reply to comment on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc("1-step request: add reply to comment").
POST("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,393 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveReplyV1Elements(t *testing.T) {
t.Parallel()
elements, err := parseCommentReplyElements(`[
{"type":"text","text":"a<b"},
{"type":"mention_user","mention_user":"ou_123"},
{"type":"link","link":"https://example.com"}
]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := driveReplyV1Elements(elements)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0]["type"] != "text_run" {
t.Fatalf("elements[0].type = %#v, want text_run", got[0]["type"])
}
textRun, ok := got[0]["text_run"].(map[string]interface{})
if !ok {
t.Fatalf("elements[0].text_run is %T, want map", got[0]["text_run"])
}
if textRun["text"] != "a&lt;b" {
t.Fatalf("elements[0].text_run.text = %#v, want escaped a&lt;b", textRun["text"])
}
person, ok := got[1]["person"].(map[string]interface{})
if !ok || got[1]["type"] != "person" {
t.Fatalf("elements[1] = %#v, want person element", got[1])
}
if person["user_id"] != "ou_123" {
t.Fatalf("elements[1].person.user_id = %#v, want ou_123", person["user_id"])
}
docsLink, ok := got[2]["docs_link"].(map[string]interface{})
if !ok || got[2]["type"] != "docs_link" {
t.Fatalf("elements[2] = %#v, want docs_link element", got[2])
}
if docsLink["url"] != "https://example.com" {
t.Fatalf("elements[2].docs_link.url = %#v, want https://example.com", docsLink["url"])
}
}
func TestDriveAddReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"reply": map[string]interface{}{
"reply_id": "reply_9",
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"收到,我来处理"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if _, ok := body["comment_id"]; ok {
t.Fatalf("request body must not carry comment_id (it rides in the URL path): %v", body)
}
content := mustMapValue(t, body["content"], "request.content")
elements := mustSliceValue(t, content["elements"], "request.content.elements")
element := mustMapValue(t, elements[0], "request.content.elements[0]")
if got := mustStringField(t, element, "type", "request.content.elements[0].type"); got != "text_run" {
t.Fatalf("request element type = %q, want text_run", got)
}
elementText := mustMapValue(t, element["text_run"], "request.content.elements[0].text_run")
if got := mustStringField(t, elementText, "text", "request.content.elements[0].text_run.text"); got != "收到,我来处理" {
t.Fatalf("text_run.text = %q, want 收到,我来处理", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_9" {
t.Fatalf("reply_id = %q, want reply_9", got)
}
if got := data["created"]; got != true {
t.Fatalf("created = %#v, want true", got)
}
}
func TestDriveAddReplyExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply from wiki"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
t.Fatalf("file_token = %q, want docxFromWiki", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
if _, ok := data["reply_id"]; ok {
t.Fatalf("reply_id should be omitted when the response carries none: %v", data)
}
}
func TestDriveAddReplyRejectsUnsupportedTargets(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `unsupported --url resource type "folder"`) {
t.Fatalf("expected unsupported-type error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveAddReplyWikiResolvesToUnsupported(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "mindnote",
"obj_token": "mindnoteFromWiki",
},
},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote", but comment reply only supports`) {
t.Fatalf("expected wiki-resolution error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestExtractDriveCreatedReplyID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
want string
}{
{name: "nil data", data: nil, want: ""},
{name: "top-level reply_id", data: map[string]interface{}{"reply_id": "r1"}, want: "r1"},
{name: "nested reply object", data: map[string]interface{}{"reply": map[string]interface{}{"reply_id": "r2"}}, want: "r2"},
{name: "nested reply without id falls through", data: map[string]interface{}{"reply": map[string]interface{}{}}, want: ""},
{
name: "reply_list wrapper",
data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{
"not-a-map",
map[string]interface{}{"reply_id": ""},
map[string]interface{}{"reply_id": "r3"},
}}},
want: "r3",
},
{name: "reply_list without match", data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{map[string]interface{}{}}}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := extractDriveCreatedReplyID(tt.data); got != tt.want {
t.Fatalf("extractDriveCreatedReplyID() = %q, want %q", got, tt.want)
}
})
}
}
func TestDriveAddReplyRejectsUnsafeCommentID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("expected comment-id validation error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--comment-id")
}
func TestDriveAddReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 1069307,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveAddReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
t.Fatalf("api[1].url = %q, want replies URL with comment ID", got)
}
}
func TestDriveAddReplyInvalidContent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `not-json`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
t.Fatalf("expected content JSON error, got %v", err)
}
}
func TestDriveAddReplyRejectsTooManyElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "caps content.elements at 100") {
t.Fatalf("expected 100-element cap error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--content")
}
func TestDriveAddReplyAcceptsMaxElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 100)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("100 elements should be accepted, got %v", err)
}
}
func TestDriveAddReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
t.Fatalf("api[0].url = %q, want reply create URL with comment ID", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if _, ok := body["comment_id"]; ok {
t.Fatalf("api[0].body must not carry comment_id: %v", body)
}
content := mustMapValue(t, body["content"], "api[0].body.content")
if _, ok := content["elements"]; !ok {
t.Fatalf("api[0].body.content.elements missing: %v", body)
}
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// driveBatchQueryCommentsMaxIDs mirrors the server-side cap on comment_ids
// per batch_query call.
const driveBatchQueryCommentsMaxIDs = 100
var driveBatchQueryCommentsOp = driveCommentOp{
Label: "comments batch query",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveBatchQueryCommentsSpec struct {
Ref driveCommentRef
CommentIDs []string
NeedReaction bool
NeedRelation bool
}
// RequestBody assembles the batch_query body for the resolved fileType.
// need_relation is absent from the platform metadata for this endpoint but
// honored live (same undocumented parameter +list-comments already uses);
// only docx returns relation data, so it is sent for docx targets only.
func (s driveBatchQueryCommentsSpec) RequestBody(fileType string) map[string]interface{} {
body := map[string]interface{}{
"comment_ids": s.CommentIDs,
}
if s.NeedReaction {
body["need_reaction"] = true
}
if s.NeedRelation && fileType == "docx" {
body["need_relation"] = true
}
return body
}
// DriveBatchQueryComments fetches comments by ID through the Drive comment
// batch_query API, while accepting Wiki URLs/tokens and resolving them to the
// underlying object.
var DriveBatchQueryComments = common.Shortcut{
Service: "drive",
Command: "+batch-query-comments",
Description: "Batch get comments by comment ID for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveBatchQueryCommentsOp),
common.Flag{Name: "comment-ids", Type: "string_slice", Desc: fmt.Sprintf("comment IDs to fetch (comma-separated or repeated flag, max %d)", driveBatchQueryCommentsMaxIDs), Required: true},
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
common.Flag{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"--comment-ids accepts comma-separated values and repeated flags, up to 100 IDs per call.",
"--need-relation returns the docx comment anchor (items[].relation with the block position); see the lark-drive comment-location guide.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveBatchQueryCommentsSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveBatchQueryCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveBatchQueryCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveBatchQueryCommentsSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveBatchQueryCommentsOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Batch querying %d comment(s) in %s...\n", len(spec.CommentIDs), common.MaskToken(target.FileToken))
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments/batch_query", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(target.FileType),
)
if err != nil {
return err
}
items := driveCommentItems(data)
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"items": items,
"count": len(items),
}), nil)
return nil
},
}
func readDriveBatchQueryCommentsSpec(runtime *common.RuntimeContext) (driveBatchQueryCommentsSpec, error) {
ref, err := resolveDriveCommentInput(driveBatchQueryCommentsOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveBatchQueryCommentsSpec{}, err
}
ids, err := normalizeDriveCommentIDs(runtime.StrSlice("comment-ids"))
if err != nil {
return driveBatchQueryCommentsSpec{}, err
}
return driveBatchQueryCommentsSpec{
Ref: ref,
CommentIDs: ids,
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
}, nil
}
func normalizeDriveCommentIDs(raw []string) ([]string, error) {
ids := make([]string, 0, len(raw))
for i, id := range raw {
id = strings.TrimSpace(id)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids element #%d is empty", i+1).WithParam("--comment-ids")
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids must contain at least one comment ID").WithParam("--comment-ids")
}
if len(ids) > driveBatchQueryCommentsMaxIDs {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids accepts at most %d comment IDs per call (got %d)", driveBatchQueryCommentsMaxIDs, len(ids)).WithParam("--comment-ids")
}
return ids, nil
}
func buildDriveBatchQueryCommentsDryRun(spec driveBatchQueryCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
// The wiki obj_type is unknown until step 1 resolves, so RequestBody
// cannot decide the docx-only need_relation gate here; surface it as a
// placeholder the same way +list-comments does.
body := spec.RequestBody("<obj_type from step 1>")
if spec.NeedRelation {
body["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> batch query comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/batch_query").
Desc("[2] Batch query comments on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(body)
}
return common.NewDryRunAPI().
Desc("1-step request: batch query comments").
POST("/open-apis/drive/v1/files/:file_token/comments/batch_query").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody(spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}

View File

@@ -0,0 +1,560 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestNormalizeDriveCommentIDs(t *testing.T) {
t.Parallel()
got, err := normalizeDriveCommentIDs([]string{" c1 ", "c2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 || got[0] != "c1" || got[1] != "c2" {
t.Fatalf("normalizeDriveCommentIDs = %v, want [c1 c2]", got)
}
if _, err := normalizeDriveCommentIDs(nil); err == nil || !strings.Contains(err.Error(), "at least one") {
t.Fatalf("expected at-least-one error, got %v", err)
}
if _, err := normalizeDriveCommentIDs([]string{"c1", " "}); err == nil || !strings.Contains(err.Error(), "element #2 is empty") {
t.Fatalf("expected empty-element error, got %v", err)
}
tooMany := make([]string, driveBatchQueryCommentsMaxIDs+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("c%d", i)
}
_, err = normalizeDriveCommentIDs(tooMany)
if err == nil || !strings.Contains(err.Error(), "at most 100") {
t.Fatalf("expected max-IDs error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--comment-ids")
}
func TestDriveBatchQueryCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
{"comment_id": "comment_2", "is_solved": true},
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1,comment_2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
ids := mustSliceValue(t, body["comment_ids"], "request.comment_ids")
if len(ids) != 2 || ids[0] != "comment_1" || ids[1] != "comment_2" {
t.Fatalf("request comment_ids = %v, want [comment_1 comment_2]", ids)
}
if _, ok := body["need_reaction"]; ok {
t.Fatalf("request should omit need_reaction by default: %v", body)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(2) {
t.Fatalf("count = %#v, want 2", got)
}
if _, ok := data["wiki_token"]; ok {
t.Fatalf("wiki_token should be omitted for direct targets: %v", data)
}
}
func TestDriveBatchQueryCommentsExecuteWikiWithReaction(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "sheetFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "sheet" {
t.Errorf("file_type = %q, want sheet", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--token", "wikiResource",
"--type", "wiki",
"--comment-ids", "comment_1",
"--need-reaction",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := body["need_reaction"]; got != true {
t.Fatalf("request need_reaction = %#v, want true", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "sheetFromWiki" {
t.Fatalf("file_token = %q, want sheetFromWiki", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
t.Fatalf("file_type = %q, want sheet", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveBatchQueryCommentsExecuteAppsPageURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/appsPageResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "apps" {
t.Errorf("file_type = %q, want apps", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.feishu.cn/page/appsPageResource/",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
t.Fatalf("file_type = %q, want apps", got)
}
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsPageResource" {
t.Fatalf("file_token = %q, want appsPageResource", got)
}
}
func TestDriveBatchQueryCommentsExecuteBaseURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/baseResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/base/baseResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
}
func TestDriveBatchQueryCommentsWikiResolvesToUnsupported(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "mindnote",
"obj_token": "mindnoteToken",
},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote"`) {
t.Fatalf("expected wiki-resolution error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveBatchQueryCommentsExecuteBaseAliasType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/baseToken/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable (base alias normalized)", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []map[string]interface{}{}},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--token", "baseToken",
"--type", "base",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveBatchQueryCommentsValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "url and token mutually exclusive",
args: []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--token", "docxResource",
"--comment-ids", "comment_1",
},
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "blank comment id element",
args: []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", " ",
},
wantErr: "element #1 is empty",
wantParam: "--comment-ids",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveBatchQueryCommentsPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 1069307,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_404",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveBatchQueryCommentsDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1,comment_2",
"--need-reaction",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/batch_query") {
t.Fatalf("api[0].url = %q, want resolved batch_query URL", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := body["need_reaction"]; got != true {
t.Fatalf("api[0].body.need_reaction = %#v, want true", got)
}
}
func TestDriveBatchQueryCommentsDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
t.Fatalf("api[1].method = %q, want POST", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
ids := mustSliceValue(t, body["comment_ids"], "api[1].body.comment_ids")
if len(ids) != 1 || ids[0] != "comment_1" {
t.Fatalf("api[1].body.comment_ids = %v, want [comment_1]", ids)
}
}
func TestDriveBatchQueryCommentsDryRunWikiNeedRelation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--need-relation",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["need_relation"]; got != "<sent only when obj_type is docx>" {
t.Fatalf("api[1].body.need_relation = %#v, want conditional placeholder", got)
}
}
func TestDriveBatchQueryCommentsOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}
func TestDriveBatchQueryCommentsNeedRelationDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := body["need_relation"]; got != true {
t.Fatalf("request need_relation = %#v, want true", got)
}
}
func TestDriveBatchQueryCommentsNeedRelationIgnoredForNonDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/sheetResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/sheets/sheetResource",
"--comment-ids", "comment_1",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if _, ok := body["need_relation"]; ok {
t.Fatalf("need_relation must be omitted for non-docx targets: %v", body)
}
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"slices"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// driveCommentOp describes one comment-family shortcut for the shared
// --url/--token/--type input resolution. Label appears in error messages;
// Types lists the wire file_type values the underlying endpoint accepts.
// Wiki URLs/tokens are always accepted as input and unwrapped to the
// underlying document, which must then land in Types.
type driveCommentOp struct {
Label string
Types []string
}
func (op driveCommentOp) supports(fileType string) bool {
return slices.Contains(op.Types, fileType)
}
// inputTypeList renders the values accepted as input (wire types plus wiki).
func (op driveCommentOp) inputTypeList() string {
return strings.Join(op.flagEnum(), ", ")
}
// targetTypeList renders the wire types the endpoint accepts (wiki excluded).
func (op driveCommentOp) targetTypeList() string {
return strings.Join(op.Types, ", ")
}
// flagEnum returns the Enum set for the --type flag: the endpoint's wire
// types plus wiki (resolved to a wire type before the API call) and the
// base product-name alias when bitable is supported (normalized to bitable).
func (op driveCommentOp) flagEnum() []string {
enum := make([]string, 0, len(op.Types)+2)
for _, t := range op.Types {
enum = append(enum, t)
if t == "bitable" {
enum = append(enum, "base")
}
}
return append(enum, "wiki")
}
// driveCommentRef is the parsed --url/--token/--type input before wiki unwrapping.
type driveCommentRef struct {
Token string
Type string
SourceFlag string
}
// driveCommentTarget is the underlying document a comment API call targets.
type driveCommentTarget struct {
FileToken string
FileType string
WikiToken string // non-empty when the input was a wiki node
}
// driveCommentTargetFlags returns the shared --url/--token/--type flag trio
// used by the comment-family shortcuts that resolve a document target.
func driveCommentTargetFlags(op driveCommentOp) []common.Flag {
return []common.Flag{
{Name: "url", Desc: fmt.Sprintf("recommended: Lark/Feishu document URL (%s); Wiki URLs are unwrapped automatically", op.inputTypeList())},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: op.flagEnum()},
}
}
// resolveDriveCommentInput parses --url/--token/--type into a driveCommentRef,
// mirroring +list-comments input handling: --url and --token are mutually
// exclusive, URLs are parsed for type+token, bare tokens require --type, and
// wiki is always accepted for later unwrapping.
func resolveDriveCommentInput(op driveCommentOp, urlInput, tokenInput, explicitType string) (driveCommentRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveCommentType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveCommentType(ref.Type)
if inputType != "" && inputType != refType {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if refType != "wiki" && !op.supports(refType) {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; %s supports %s",
sourceFlag,
refType,
op.Label,
op.inputTypeList(),
).WithParam(sourceFlag)
}
return driveCommentRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
const refType = "apps"
if inputType != "" && inputType != refType {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !op.supports(refType) {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; %s supports %s",
sourceFlag,
refType,
op.Label,
op.inputTypeList(),
).WithParam(sourceFlag)
}
return driveCommentRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: %s)", sourceFlag, op.inputTypeList()).WithParam("--type")
}
if inputType != "wiki" && !op.supports(inputType) {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: %s", inputType, op.inputTypeList()).WithParam("--type")
}
return driveCommentRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
// normalizeDriveCommentType maps compatibility aliases to wire values
// (base → bitable) so type checks and error messages use one vocabulary.
func normalizeDriveCommentType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
// resolveDriveCommentTarget unwraps wiki refs to the underlying document via
// wiki get_node and validates the resolved type against op.Types.
func resolveDriveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, op driveCommentOp, ref driveCommentRef) (driveCommentTarget, error) {
if ref.Type != "wiki" {
return driveCommentTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveCommentTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveCommentType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType == "wiki" || !op.supports(objType) {
return driveCommentTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but %s only supports %s",
objType,
op.Label,
op.targetTypeList(),
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveCommentTarget{FileToken: objToken, FileType: objType, WikiToken: ref.Token}, nil
}
// validateDriveCommentPathID validates a comment/reply identifier destined
// for a URL path segment.
func validateDriveCommentPathID(value, flagName string) error {
if strings.TrimSpace(value) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must not be empty", flagName).WithParam(flagName)
}
if err := validate.ResourceName(strings.TrimSpace(value), flagName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName)
}
return nil
}
// driveCommentItems extracts data.items for output, normalizing a missing or
// null field to an empty slice: emitting the server's shape verbatim would
// surface "items": null, which breaks jq consumers iterating .data.items[].
func driveCommentItems(data map[string]interface{}) []interface{} {
if items := common.GetSlice(data, "items"); items != nil {
return items
}
return []interface{}{}
}
// driveCommentTargetOutput assembles the output fields shared by the
// comment-family shortcuts: the resolved target plus the wiki origin, if any.
func driveCommentTargetOutput(target driveCommentTarget, extra map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
for key, value := range extra {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func assertDriveCommentValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
}
// assertDriveCommentAPIError asserts the error kept the typed API contract:
// CallAPITyped errors must reach the caller unchanged, message-only checks
// would still pass if a refactor wrapped them into untyped errors.
func assertDriveCommentAPIError(t *testing.T, err error, wantCode int) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype == "" {
t.Fatalf("subtype is empty, want populated")
}
if problem.Code != wantCode {
t.Fatalf("code = %d, want %d", problem.Code, wantCode)
}
}
func TestResolveDriveCommentInput(t *testing.T) {
t.Parallel()
op := driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "sheet", "file", "slides"}}
docOnlyOp := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
tests := []struct {
name string
op driveCommentOp
urlInput string
rawInput string
docType string
wantToken string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
op: op,
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantToken: "docxResource",
wantType: "docx",
},
{
name: "url wiki always accepted",
op: docOnlyOp,
urlInput: "https://example.larksuite.com/wiki/wikiResource",
wantToken: "wikiResource",
wantType: "wiki",
},
{
name: "token flag also accepts url",
op: op,
rawInput: "https://example.larksuite.com/sheets/sheetResource",
wantToken: "sheetResource",
wantType: "sheet",
},
{
name: "bare token with type",
op: op,
rawInput: "docxResource",
docType: "docx",
wantToken: "docxResource",
wantType: "docx",
},
{
name: "bare wiki token",
op: docOnlyOp,
rawInput: "wikiResource",
docType: "wiki",
wantToken: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
op: op,
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "missing input",
op: op,
wantErr: "specify --url or --token",
wantParam: "--url",
},
{
name: "bare token needs type",
op: op,
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
op: op,
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
op: op,
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported --url resource type",
wantParam: "--url",
},
{
name: "unsupported url type for doc-only op",
op: docOnlyOp,
urlInput: "https://example.larksuite.com/sheets/sheetResource",
wantErr: "comment reply supports doc, docx, wiki",
wantParam: "--url",
},
{
name: "apps page url",
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
urlInput: "https://example.feishu.cn/page/appsPageResource/",
wantToken: "appsPageResource",
wantType: "apps",
},
{
name: "apps page url rejected by op without apps",
op: docOnlyOp,
urlInput: "https://example.feishu.cn/page/appsPageResource",
wantErr: `unsupported --url resource type "apps"`,
wantParam: "--url",
},
{
name: "apps page url conflicts with explicit type",
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
urlInput: "https://example.feishu.cn/page/appsPageResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "base alias normalized in error",
op: op,
urlInput: "https://example.larksuite.com/base/baseResource",
wantErr: `unsupported --url resource type "bitable"`,
wantParam: "--url",
},
{
name: "unrecognized url",
op: op,
urlInput: "https://example.com/unknown/path",
wantErr: "unsupported --url URL",
wantParam: "--url",
},
{
name: "bare token with path fragments",
op: op,
rawInput: "abc/def",
docType: "docx",
wantErr: "invalid bare token",
wantParam: "--token",
},
{
name: "invalid explicit type",
op: docOnlyOp,
rawInput: "sheetResource",
docType: "sheet",
wantErr: "invalid --type",
wantParam: "--type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveCommentInput(tt.op, tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantToken || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
}
})
}
}
func TestValidateDriveCommentPathID(t *testing.T) {
t.Parallel()
if err := validateDriveCommentPathID("7457000000000000001", "--comment-id"); err != nil {
t.Fatalf("unexpected error for valid ID: %v", err)
}
tests := []struct {
name string
value string
wantErr string
}{
{name: "empty", value: " ", wantErr: "must not be empty"},
{name: "path traversal", value: "../admin", wantErr: "path traversal"},
{name: "url metacharacters", value: "abc?x=1", wantErr: "invalid characters"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCommentPathID(tt.value, "--comment-id")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, "--comment-id")
})
}
}
func TestDriveCommentOpTypeHelpers(t *testing.T) {
t.Parallel()
op := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
if got := op.inputTypeList(); got != "doc, docx, wiki" {
t.Fatalf("inputTypeList() = %q, want %q", got, "doc, docx, wiki")
}
if got := op.targetTypeList(); got != "doc, docx" {
t.Fatalf("targetTypeList() = %q, want %q", got, "doc, docx")
}
if got := op.flagEnum(); len(got) != 3 || got[2] != "wiki" {
t.Fatalf("flagEnum() = %v, want types plus trailing wiki", got)
}
if !op.supports("docx") || op.supports("sheet") || op.supports("wiki") {
t.Fatalf("supports() misclassified: docx=%v sheet=%v wiki=%v", op.supports("docx"), op.supports("sheet"), op.supports("wiki"))
}
}

View File

@@ -0,0 +1,358 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveCopyMaxNameBytes = 256
// driveCopyMySpaceSentinel lets callers target the My Space root folder
// without knowing its token; Execute resolves it via the root-folder-meta
// endpoint (absent from platform metadata, path fixed per official docs).
driveCopyMySpaceSentinel = "my_space"
driveCopyRootFolderMetaPath = "/open-apis/drive/explorer/v2/root_folder/meta"
)
var driveCopyTypes = []string{"doc", "docx", "sheet", "file", "mindnote", "slides", "bitable", "base", "wiki"}
type driveCopyRef struct {
Token string
Type string
SourceFlag string
}
type driveCopyExtra struct {
Key string
Value string
}
type driveCopySpec struct {
Ref driveCopyRef
Name string
FolderToken string // empty when FolderMySpace is set
FolderMySpace bool
Extras []driveCopyExtra
}
// DriveCopy copies a Drive file into a target folder through the Drive copy
// API, with URL parsing. Wiki inputs are rejected with a redirect to the
// existing `wiki +node-copy` shortcut.
var DriveCopy = common.Shortcut{
Service: "drive",
Command: "+copy",
Description: "Copy a doc/docx/sheet/file/mindnote/slides/base(bitable) into a target folder, with URL parsing; wiki inputs are redirected to wiki +node-copy",
Risk: "write",
Scopes: []string{"docs:document:copy"},
ConditionalScopes: []string{"drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/mindnote/slides/base/bitable)"},
{Name: "token", Desc: "document token or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveCopyTypes},
{Name: "name", Desc: "name for the copied file, up to 256 bytes", Required: true},
{Name: "folder-token", Desc: "target folder token, folder URL, or the constant my_space to copy into the caller's My Space root folder", Required: true},
{Name: "extra", Type: "string_array", Desc: "repeatable key=value pair forwarded verbatim as a custom copy parameter, e.g. --extra target_type=docx to convert a legacy doc into a docx copy"},
},
Tips: []string{
"The source type must match the real file type; the API rejects mismatches.",
"Use `--extra target_type=docx` with a legacy doc source to create the copy as a new-version docx.",
"`--folder-token my_space` resolves the caller's My Space root folder automatically; resolution needs the drive:drive.metadata:readonly (or drive:drive) scope.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveCopySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveCopySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveCopyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveCopySpec(runtime)
if err != nil {
return err
}
folderToken := spec.FolderToken
if spec.FolderMySpace {
folderToken, err = resolveDriveCopyMySpaceRoot(runtime)
if err != nil {
return err
}
}
fmt.Fprintf(runtime.IO().ErrOut, "Copying %s %s to folder %s...\n",
spec.Ref.Type, common.MaskToken(spec.Ref.Token), common.MaskToken(folderToken))
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/files/%s/copy", validate.EncodePathSegment(spec.Ref.Token)),
nil,
buildDriveCopyBody(spec, folderToken),
)
if err != nil {
return err
}
runtime.Out(buildDriveCopyOutput(runtime, spec, folderToken, data), nil)
return nil
},
}
func readDriveCopySpec(runtime *common.RuntimeContext) (driveCopySpec, error) {
ref, err := resolveDriveCopyInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveCopySpec{}, err
}
spec := driveCopySpec{
Ref: ref,
Name: strings.TrimSpace(runtime.Str("name")),
}
spec.FolderToken, spec.FolderMySpace, err = resolveDriveCopyFolderToken(runtime.Str("folder-token"))
if err != nil {
return driveCopySpec{}, err
}
spec.Extras, err = parseDriveCopyExtras(runtime.StrArray("extra"))
if err != nil {
return driveCopySpec{}, err
}
if err := validateDriveCopySpec(spec); err != nil {
return driveCopySpec{}, err
}
return spec, nil
}
// parseDriveCopyExtras converts repeated `key=value` specs into the API's
// extra parameter shape, preserving order and transcribing values verbatim.
func parseDriveCopyExtras(specs []string) ([]driveCopyExtra, error) {
if len(specs) == 0 {
return nil, nil
}
extras := make([]driveCopyExtra, 0, len(specs))
for _, spec := range specs {
key, value, found := strings.Cut(spec, "=")
if !found {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: expected format key=value", spec).WithParam("--extra")
}
key = strings.TrimSpace(key)
if key == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: key must not be empty", spec).WithParam("--extra")
}
if value == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: value must not be empty", spec).WithParam("--extra")
}
extras = append(extras, driveCopyExtra{Key: key, Value: value})
}
return extras, nil
}
func validateDriveCopySpec(spec driveCopySpec) error {
if spec.Name == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty or whitespace-only").WithParam("--name")
}
if len(spec.Name) > driveCopyMaxNameBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds %d bytes (got %d)", driveCopyMaxNameBytes, len(spec.Name)).WithParam("--name")
}
return nil
}
func resolveDriveCopyInput(urlInput, tokenInput, explicitType string) (driveCopyRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveCopyType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveCopyType(ref.Type)
if inputType != "" && inputType != refType {
return driveCopyRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if refType == "wiki" {
return driveCopyRef{}, driveCopyWikiRedirectError(sourceFlag, ref.Token)
}
if !driveCopyTypeSupported(refType) {
return driveCopyRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; drive copy supports doc, docx, sheet, file, mindnote, slides, and bitable/base",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveCopyRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, mindnote, slides, bitable, base)", sourceFlag).WithParam("--type")
}
if inputType == "wiki" {
return driveCopyRef{}, driveCopyWikiRedirectError("--type", raw)
}
if !driveCopyTypeSupported(inputType) {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, mindnote, slides, bitable, base", inputType).WithParam("--type")
}
return driveCopyRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
// driveCopyWikiRedirectError guides wiki inputs to the dedicated wiki copy
// command instead of the Drive copy API, which cannot place copies in the
// wiki tree.
func driveCopyWikiRedirectError(param, nodeToken string) *errs.ValidationError {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki node %q cannot be copied with drive +copy; use wiki +node-copy instead",
nodeToken,
).WithParam(param).WithHint(
"run: lark-cli wiki +node-copy --space-id <space-id> --node-token %s --target-space-id <target-space-id> (or --target-parent-node-token); resolve <space-id> with: lark-cli wiki +node-get --token %s",
nodeToken,
nodeToken,
)
}
func resolveDriveCopyFolderToken(input string) (string, bool, error) {
input = strings.TrimSpace(input)
if strings.EqualFold(input, driveCopyMySpaceSentinel) {
return "", true, nil
}
if ref, ok := common.ParseResourceURL(input); ok {
if ref.Type != "folder" {
return "", false, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--folder-token URL resolves to %q, not a folder; pass a folder URL, a folder token, or my_space",
ref.Type,
).WithParam("--folder-token")
}
return ref.Token, false, nil
}
if err := validate.ResourceName(input, "--folder-token"); err != nil {
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
return input, false, nil
}
// resolveDriveCopyMySpaceRoot fetches the caller's My Space root folder token.
// The endpoint is absent from platform metadata; the path follows the official
// get-root-folder-meta documentation and works for both user and bot tokens.
func resolveDriveCopyMySpaceRoot(runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "Resolving My Space root folder...\n")
data, err := runtime.CallAPITyped("GET", driveCopyRootFolderMetaPath, nil, nil)
if err != nil {
return "", err
}
token := strings.TrimSpace(common.GetString(data, "token"))
if token == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root folder meta returned an empty token")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved My Space root: %s\n", common.MaskToken(token))
return token, nil
}
func normalizeDriveCopyType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
func driveCopyTypeSupported(docType string) bool {
switch normalizeDriveCopyType(docType) {
case "doc", "docx", "sheet", "file", "mindnote", "slides", "bitable":
return true
default:
return false
}
}
func buildDriveCopyBody(spec driveCopySpec, folderToken string) map[string]interface{} {
body := map[string]interface{}{
"name": spec.Name,
"type": spec.Ref.Type,
"folder_token": folderToken,
}
if len(spec.Extras) > 0 {
extras := make([]map[string]interface{}, 0, len(spec.Extras))
for _, extra := range spec.Extras {
extras = append(extras, map[string]interface{}{"key": extra.Key, "value": extra.Value})
}
body["extra"] = extras
}
return body
}
func buildDriveCopyDryRun(spec driveCopySpec) *common.DryRunAPI {
if spec.FolderMySpace {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve My Space root -> copy").
GET(driveCopyRootFolderMetaPath).
Desc("[1] Resolve the caller's My Space root folder token").
POST("/open-apis/drive/v1/files/:file_token/copy").
Desc("[2] Copy file into the resolved root folder").
Body(buildDriveCopyBody(spec, "<root folder token from step 1>")).
Set("file_token", spec.Ref.Token)
}
return common.NewDryRunAPI().
Desc("1-step request: copy file into target folder").
POST("/open-apis/drive/v1/files/:file_token/copy").
Body(buildDriveCopyBody(spec, spec.FolderToken)).
Set("file_token", spec.Ref.Token)
}
func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"copied": true,
"source_file_token": spec.Ref.Token,
"source_type": spec.Ref.Type,
"folder_token": folderToken,
}
file := common.GetMap(data, "file")
if token := common.GetString(file, "token"); token != "" {
out["file_token"] = token
if url := common.GetString(file, "url"); url != "" {
out["url"] = url
} else if built := common.BuildResourceURL(runtime.Config.Brand, common.GetString(file, "type"), token); built != "" {
out["url"] = built
}
}
if fileType := common.GetString(file, "type"); fileType != "" {
out["file_type"] = fileType
}
if name := common.GetString(file, "name"); name != "" {
out["name"] = name
}
return out
}

View File

@@ -0,0 +1,811 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveCopyInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantToken string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxCopySource?from=share",
wantToken: "docxCopySource",
wantType: "docx",
},
{
name: "url base normalizes to bitable",
urlInput: "https://example.larksuite.com/base/bitableCopySource",
wantToken: "bitableCopySource",
wantType: "bitable",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/sheets/sheetCopySource",
wantToken: "sheetCopySource",
wantType: "sheet",
},
{
name: "bare token with type",
rawInput: "mindnoteCopySource",
docType: "mindnote",
wantToken: "mindnoteCopySource",
wantType: "mindnote",
},
{
name: "bare token with base alias",
rawInput: "bitableCopySource",
docType: "base",
wantToken: "bitableCopySource",
wantType: "bitable",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxCopySource",
rawInput: "docxCopySource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "missing input",
wantErr: "specify --url or --token",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxCopySource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/docx/docxCopySource",
docType: "sheet",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "folder url unsupported as source",
urlInput: "https://example.larksuite.com/drive/folder/folderCopySource",
wantErr: "unsupported",
wantParam: "--url",
},
{
name: "unrecognized url",
urlInput: "https://example.larksuite.com/unknown/path",
wantErr: "unsupported --url URL",
wantParam: "--url",
},
{
name: "token with path fragments",
rawInput: "token/with/slash",
wantErr: "invalid bare token",
wantParam: "--token",
},
{
name: "invalid bare type",
rawInput: "someToken",
docType: "folder",
wantErr: "invalid --type",
wantParam: "--type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCopyValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantToken || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
}
})
}
}
func TestResolveDriveCopyInputWikiRedirect(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantParam string
}{
{
name: "wiki url",
urlInput: "https://example.larksuite.com/wiki/wikiCopySource",
wantParam: "--url",
},
{
name: "wiki url via token flag",
rawInput: "https://example.larksuite.com/wiki/wikiCopySource",
wantParam: "--token",
},
{
name: "bare token with wiki type",
rawInput: "wikiCopySource",
docType: "wiki",
wantParam: "--type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType)
if err == nil {
t.Fatal("expected wiki redirect error, got nil")
}
assertDriveCopyValidationError(t, err, tt.wantParam)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(validationErr.Message, "wiki +node-copy") {
t.Fatalf("message should redirect to wiki +node-copy, got %q", validationErr.Message)
}
if !strings.Contains(validationErr.Hint, "wiki +node-copy --space-id") {
t.Fatalf("hint should carry the wiki +node-copy command, got %q", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "--node-token wikiCopySource") {
t.Fatalf("hint should carry the parsed node token, got %q", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "wiki +node-get --token wikiCopySource") {
t.Fatalf("hint should explain how to resolve the space id, got %q", validationErr.Hint)
}
})
}
}
func TestResolveDriveCopyFolderToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantToken string
wantMySpace bool
wantErr string
}{
{
name: "bare folder token",
input: "folderCopyTarget",
wantToken: "folderCopyTarget",
},
{
name: "folder url",
input: "https://example.larksuite.com/drive/folder/folderCopyTarget",
wantToken: "folderCopyTarget",
},
{
name: "my_space sentinel",
input: "my_space",
wantMySpace: true,
},
{
name: "my_space sentinel is case-insensitive and trimmed",
input: " MY_SPACE ",
wantMySpace: true,
},
{
name: "non-folder url",
input: "https://example.larksuite.com/docx/docxCopyTarget",
wantErr: "not a folder",
},
{
name: "empty input",
input: " ",
wantErr: "--folder-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, mySpace, err := resolveDriveCopyFolderToken(tt.input)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCopyValidationError(t, err, "--folder-token")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if mySpace != tt.wantMySpace {
t.Fatalf("mySpace = %v, want %v", mySpace, tt.wantMySpace)
}
if got != tt.wantToken {
t.Fatalf("token = %q, want %q", got, tt.wantToken)
}
})
}
}
func TestParseDriveCopyExtras(t *testing.T) {
t.Parallel()
extras, err := parseDriveCopyExtras(nil)
if err != nil || extras != nil {
t.Fatalf("empty specs = (%#v, %v), want (nil, nil)", extras, err)
}
extras, err = parseDriveCopyExtras([]string{"target_type=docx", "flag=a=b"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []driveCopyExtra{{Key: "target_type", Value: "docx"}, {Key: "flag", Value: "a=b"}}
if len(extras) != len(want) {
t.Fatalf("extras = %#v, want %#v", extras, want)
}
for i := range want {
if extras[i] != want[i] {
t.Fatalf("extras[%d] = %#v, want %#v (order and values must be preserved verbatim)", i, extras[i], want[i])
}
}
for _, bad := range []string{"no-separator", "=docx", " =docx", "target_type="} {
_, err := parseDriveCopyExtras([]string{bad})
if err == nil || !strings.Contains(err.Error(), "invalid --extra") {
t.Fatalf("spec %q: expected invalid --extra error, got %v", bad, err)
}
assertDriveCopyValidationError(t, err, "--extra")
}
}
func TestBuildDriveCopyBodyExtras(t *testing.T) {
t.Parallel()
spec := driveCopySpec{
Ref: driveCopyRef{Token: "docCopySource", Type: "doc", SourceFlag: "--url"},
Name: "Copied doc",
FolderToken: "folderCopyTarget",
}
if _, ok := buildDriveCopyBody(spec, spec.FolderToken)["extra"]; ok {
t.Fatal("body should omit extra when no --extra is passed")
}
spec.Extras = []driveCopyExtra{{Key: "target_type", Value: "docx"}}
body := buildDriveCopyBody(spec, spec.FolderToken)
extras, ok := body["extra"].([]map[string]interface{})
if !ok || len(extras) != 1 {
t.Fatalf("body extra = %#v, want 1 key/value entry", body["extra"])
}
if extras[0]["key"] != "target_type" || extras[0]["value"] != "docx" {
t.Fatalf("extra[0] = %#v, want target_type=docx", extras[0])
}
}
func TestValidateDriveCopySpec(t *testing.T) {
t.Parallel()
base := driveCopySpec{
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
Name: "Copy name",
FolderToken: "folderCopyTarget",
}
if err := validateDriveCopySpec(base); err != nil {
t.Fatalf("unexpected error for valid spec: %v", err)
}
empty := base
empty.Name = ""
err := validateDriveCopySpec(empty)
if err == nil || !strings.Contains(err.Error(), "--name must not be empty") {
t.Fatalf("expected empty-name error, got %v", err)
}
assertDriveCopyValidationError(t, err, "--name")
long := base
long.Name = strings.Repeat("字", 90) // 270 bytes in UTF-8
err = validateDriveCopySpec(long)
if err == nil || !strings.Contains(err.Error(), "exceeds 256 bytes") {
t.Fatalf("expected name-length error, got %v", err)
}
assertDriveCopyValidationError(t, err, "--name")
}
func assertDriveCopyValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected cause on direct validation error: %v", cause)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
}
func TestDriveCopyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
copyStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"file": map[string]interface{}{
"token": "docxCopyResult",
"type": "docx",
"name": "Copied doc",
"url": "https://example.larksuite.com/docx/docxCopyResult",
"parent_token": "folderCopyTarget",
},
},
},
}
reg.Register(copyStub)
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--url", "https://example.larksuite.com/docx/docxCopySource",
"--name", "Copied doc",
"--folder-token", "https://example.larksuite.com/drive/folder/folderCopyTarget",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody))
}
if got := requestBody["name"]; got != "Copied doc" {
t.Fatalf("body name = %#v, want Copied doc", got)
}
if got := requestBody["type"]; got != "docx" {
t.Fatalf("body type = %#v, want docx", got)
}
if got := requestBody["folder_token"]; got != "folderCopyTarget" {
t.Fatalf("body folder_token = %#v, want folderCopyTarget (parsed from folder URL)", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := data["copied"]; got != true {
t.Fatalf("copied = %#v, want true", got)
}
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxCopyResult" {
t.Fatalf("file_token = %q, want docxCopyResult", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := mustStringField(t, data, "url", "data.url"); got != "https://example.larksuite.com/docx/docxCopyResult" {
t.Fatalf("url = %q, want backend url", got)
}
if got := mustStringField(t, data, "source_file_token", "data.source_file_token"); got != "docxCopySource" {
t.Fatalf("source_file_token = %q, want docxCopySource", got)
}
}
func TestDriveCopyExecuteBuildsURLFallback(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/sheetCopySource/copy",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"file": map[string]interface{}{
"token": "sheetCopyResult",
"type": "sheet",
"name": "Copied sheet",
},
},
},
})
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "sheetCopySource",
"--type", "sheet",
"--name", "Copied sheet",
"--folder-token", "folderCopyTarget",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
url := mustStringField(t, data, "url", "data.url")
if !strings.HasSuffix(url, "/sheets/sheetCopyResult") {
t.Fatalf("url = %q, want built fallback ending in /sheets/sheetCopyResult", url)
}
}
func TestDriveCopyExecuteAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
Body: map[string]interface{}{
"code": 1248006,
"msg": "no permission",
},
})
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "folderCopyTarget",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected API error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != 1248006 {
t.Fatalf("problem code = %d, want 1248006", problem.Code)
}
}
func TestDriveCopyMountedDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--url", "https://example.larksuite.com/docx/docxCopySource",
"--name", "Copied doc",
"--folder-token", "folderCopyTarget",
"--extra", "target_type=docx",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
if got := out["dry_run"]; got != true {
t.Fatalf("dry_run = %#v, want true\nstdout:\n%s", got, stdout.String())
}
data := mustMapValue(t, out["data"], "data")
apis, ok := data["api"].([]interface{})
if !ok || len(apis) != 1 {
t.Fatalf("expected 1 api entry, got %#v\nstdout:\n%s", data["api"], stdout.String())
}
call := mustMapValue(t, apis[0], "api.0")
if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" {
t.Fatalf("url = %#v, want resolved copy endpoint", got)
}
body := mustMapValue(t, call["body"], "api.0.body")
if got := body["folder_token"]; got != "folderCopyTarget" {
t.Fatalf("body folder_token = %#v, want folderCopyTarget", got)
}
extras, ok := body["extra"].([]interface{})
if !ok || len(extras) != 1 {
t.Fatalf("body extra = %#v, want 1 entry", body["extra"])
}
extra := mustMapValue(t, extras[0], "api.0.body.extra.0")
if extra["key"] != "target_type" || extra["value"] != "docx" {
t.Fatalf("extra[0] = %#v, want target_type=docx", extra)
}
}
func TestDriveCopyMountedMySpaceExecute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"id": "7000000000000000001",
"token": "rootFolderResolved",
"user_id": "7000000000000000002",
},
},
})
copyStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"file": map[string]interface{}{
"token": "docxCopyResult",
"type": "docx",
"name": "Copied doc",
},
},
},
}
reg.Register(copyStub)
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "my_space",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody))
}
if got := requestBody["folder_token"]; got != "rootFolderResolved" {
t.Fatalf("body folder_token = %#v, want resolved root token", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "folder_token", "data.folder_token"); got != "rootFolderResolved" {
t.Fatalf("output folder_token = %q, want resolved root token", got)
}
}
func TestDriveCopyMountedMySpaceRootResolveErrors(t *testing.T) {
t.Run("api error propagates", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
Body: map[string]interface{}{
"code": 99991663,
"msg": "token invalid",
},
})
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "my_space",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected root resolve error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != 99991663 {
t.Fatalf("problem code = %d, want 99991663", problem.Code)
}
})
t.Run("empty token is an internal error", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"id": "7000000000000000001"},
},
})
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "my_space",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("expected empty-token error, got %v", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
}
})
}
func TestBuildDriveCopyDryRunMySpace(t *testing.T) {
t.Parallel()
spec := driveCopySpec{
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
Name: "Copied doc",
FolderMySpace: true,
}
raw, err := json.Marshal(buildDriveCopyDryRun(spec))
if err != nil {
t.Fatalf("failed to marshal dry-run preview: %v", err)
}
payload := decodeJSONMap(t, string(raw))
apis, ok := payload["api"].([]interface{})
if !ok || len(apis) != 2 {
t.Fatalf("expected 2 api entries, got %#v", payload["api"])
}
step1 := mustMapValue(t, apis[0], "api.0")
if step1["method"] != "GET" || step1["url"] != "/open-apis/drive/explorer/v2/root_folder/meta" {
t.Fatalf("api.0 = %#v, want root folder meta GET", step1)
}
step2 := mustMapValue(t, apis[1], "api.1")
body := mustMapValue(t, step2["body"], "api.1.body")
if got := body["folder_token"]; got != "<root folder token from step 1>" {
t.Fatalf("api.1.body.folder_token = %#v, want placeholder", got)
}
}
func TestDriveCopyMountedWikiInputFailsValidation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--url", "https://example.larksuite.com/wiki/wikiCopySource",
"--name", "Copied wiki",
"--folder-token", "folderCopyTarget",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected wiki redirect error, got nil")
}
assertDriveCopyValidationError(t, err, "--url")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(validationErr.Hint, "wiki +node-copy") {
t.Fatalf("hint should redirect to wiki +node-copy, got %q", validationErr.Hint)
}
}
func TestDriveCopyMountedFolderAndNameValidation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "https://example.larksuite.com/docx/notAFolder",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "not a folder") {
t.Fatalf("expected non-folder target error, got %v", err)
}
assertDriveCopyValidationError(t, err, "--folder-token")
err = mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", " ",
"--folder-token", "folderCopyTarget",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--name must not be empty") {
t.Fatalf("expected whitespace-name error, got %v", err)
}
assertDriveCopyValidationError(t, err, "--name")
err = mountAndRunDrive(t, DriveCopy, []string{
"+copy",
"--token", "docxCopySource",
"--type", "docx",
"--name", "Copied doc",
"--folder-token", "folderCopyTarget",
"--extra", "no-separator",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "expected format key=value") {
t.Fatalf("expected malformed --extra error, got %v", err)
}
assertDriveCopyValidationError(t, err, "--extra")
}
func TestBuildDriveCopyDryRun(t *testing.T) {
t.Parallel()
spec := driveCopySpec{
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
Name: "Copied doc",
FolderToken: "folderCopyTarget",
}
preview := buildDriveCopyDryRun(spec)
raw, err := json.Marshal(preview)
if err != nil {
t.Fatalf("failed to marshal dry-run preview: %v", err)
}
payload := decodeJSONMap(t, string(raw))
apis, ok := payload["api"].([]interface{})
if !ok || len(apis) != 1 {
t.Fatalf("expected 1 api entry, got %#v", payload["api"])
}
call := mustMapValue(t, apis[0], "api.0")
if got := call["method"]; got != "POST" {
t.Fatalf("method = %#v, want POST", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" {
t.Fatalf("url = %#v, want resolved copy endpoint", got)
}
body := mustMapValue(t, call["body"], "api.0.body")
if got := body["type"]; got != "docx" {
t.Fatalf("body type = %#v, want docx", got)
}
if got := body["name"]; got != "Copied doc" {
t.Fatalf("body name = %#v, want Copied doc", got)
}
if got := body["folder_token"]; got != "folderCopyTarget" {
t.Fatalf("body folder_token = %#v, want folderCopyTarget", got)
}
if got := payload["file_token"]; got != "docxCopySource" {
t.Fatalf("file_token = %#v, want docxCopySource", got)
}
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveDeleteReplyOp = driveCommentOp{
Label: "reply delete",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveDeleteReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyID string
}
// DriveDeleteReply deletes a reply of a comment through the Drive comment
// reply delete API, while accepting Wiki URLs/tokens and resolving them to
// the underlying object.
var DriveDeleteReply = common.Shortcut{
Service: "drive",
Command: "+delete-reply",
Description: "Delete a reply of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "high-risk-write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveDeleteReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID the reply belongs to (from drive +list-comments)", Required: true},
common.Flag{Name: "reply-id", Desc: "reply ID to delete (from drive +list-comments items[].reply_list.replies[].reply_id)", Required: true},
),
Tips: []string{
"Reply IDs come from `drive +list-comments` (items[].reply_list.replies[].reply_id).",
"Deletion is permanent; there is no undo or trash for comment replies.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveDeleteReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveDeleteReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveDeleteReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveDeleteReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveDeleteReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
validate.EncodePathSegment(spec.ReplyID),
)
if _, err := runtime.CallAPITyped(
"DELETE",
path,
map[string]interface{}{"file_type": target.FileType},
nil,
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"reply_id": spec.ReplyID,
"deleted": true,
}), nil)
return nil
},
}
func readDriveDeleteReplySpec(runtime *common.RuntimeContext) (driveDeleteReplySpec, error) {
ref, err := resolveDriveCommentInput(driveDeleteReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveDeleteReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveDeleteReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
return driveDeleteReplySpec{}, err
}
return driveDeleteReplySpec{
Ref: ref,
CommentID: commentID,
ReplyID: replyID,
}, nil
}
func buildDriveDeleteReplyDryRun(spec driveDeleteReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> delete reply").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
DELETE("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
Desc("[2] Delete reply on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: delete reply").
DELETE("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveDeleteReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
t.Fatalf("reply_id = %q, want reply_2", got)
}
if got := data["deleted"]; got != true {
t.Fatalf("deleted = %#v, want true", got)
}
}
func TestDriveDeleteReplyExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "file",
"obj_token": "fileFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fileFromWiki/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "file" {
t.Errorf("file_type = %q, want file", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
t.Fatalf("file_type = %q, want file", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveDeleteReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe reply id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "../reply",
},
wantErr: "path traversal",
wantParam: "--reply-id",
},
{
name: "empty reply id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", " ",
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "unsafe comment id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--reply-id", "reply_2",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "unsupported url type",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
},
wantErr: "reply delete supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveDeleteReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
Body: map[string]interface{}{
"code": 1069307,
"msg": "reply not found",
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "reply not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveDeleteReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveDeleteReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "DELETE" {
t.Fatalf("api[0].method = %q, want DELETE", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
}
func TestDriveDeleteReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "DELETE" {
t.Fatalf("api[1].method = %q, want DELETE", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
}
}

View File

@@ -348,7 +348,7 @@ func driveListCommentsScopeParam(scope string) (*bool, bool) {
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
items := driveCommentItems(data)
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,

View File

@@ -525,3 +525,38 @@ func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": ""},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveListRepliesOp = driveCommentOp{
Label: "replies list",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveListRepliesSpec struct {
Ref driveCommentRef
CommentID string
PageSize int
PageToken string
NeedReaction bool
}
// DriveListReplies lists the replies of one comment through the Drive comment
// reply list API, while accepting Wiki URLs/tokens and resolving them to the
// underlying object.
var DriveListReplies = common.Shortcut{
Service: "drive",
Command: "+list-replies",
Description: "List replies of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveListRepliesOp),
common.Flag{Name: "comment-id", Desc: "comment ID whose replies to list (from drive +list-comments)", Required: true},
common.Flag{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
common.Flag{Name: "page-token", Desc: "pagination token from previous response"},
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on replies"},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"The root reply (the comment body itself) is the earliest-created reply: it is items[0] of the FIRST page only (no --page-token); items[0] of later pages is a regular reply.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveListRepliesSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListRepliesSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListRepliesDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListRepliesSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveListRepliesOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing replies of comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
data, err := runtime.CallAPITyped(
"GET",
path,
buildDriveListRepliesParams(spec, target.FileType),
nil,
)
if err != nil {
return err
}
items := driveCommentItems(data)
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}), nil)
return nil
},
}
func readDriveListRepliesSpec(runtime *common.RuntimeContext) (driveListRepliesSpec, error) {
ref, err := resolveDriveCommentInput(driveListRepliesOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListRepliesSpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveListRepliesSpec{}, err
}
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 100 {
return driveListRepliesSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
return driveListRepliesSpec{
Ref: ref,
CommentID: commentID,
PageSize: pageSize,
PageToken: strings.TrimSpace(runtime.Str("page-token")),
NeedReaction: runtime.Bool("need-reaction"),
}, nil
}
func buildDriveListRepliesParams(spec driveListRepliesSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if spec.NeedReaction {
params["need_reaction"] = true
}
return params
}
func buildDriveListRepliesDryRun(spec driveListRepliesSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comment replies").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
Desc("[2] List replies of comment on resolved document").
Params(buildDriveListRepliesParams(spec, "<obj_type from step 1>")).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc("1-step request: list comment replies").
GET("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
Params(buildDriveListRepliesParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveListRepliesExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("page_size"); got != "50" {
t.Errorf("page_size = %q, want 50 (default)", got)
}
if query.Has("page_token") {
t.Errorf("page_token should be omitted when not set, got %q", query.Get("page_token"))
}
if query.Has("need_reaction") {
t.Errorf("need_reaction should be omitted when not set, got %q", query.Get("need_reaction"))
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"reply_id": "reply_1",
"content": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"type": "text_run",
"text_run": map[string]interface{}{"text": "根回复正文"},
},
},
},
},
map[string]interface{}{"reply_id": "reply_2"},
},
"has_more": true,
"page_token": "next_page",
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
items := mustSliceValue(t, data["items"], "data.items")
if len(items) != 2 {
t.Fatalf("len(items) = %d, want 2", len(items))
}
firstItem := mustMapValue(t, items[0], "data.items[0]")
if got := mustStringField(t, firstItem, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
}
firstContent := mustMapValue(t, firstItem["content"], "data.items[0].content")
firstElements := mustSliceValue(t, firstContent["elements"], "data.items[0].content.elements")
firstElement := mustMapValue(t, firstElements[0], "data.items[0].content.elements[0]")
firstText := mustMapValue(t, firstElement["text_run"], "data.items[0].content.elements[0].text_run")
if got := mustStringField(t, firstText, "text", "data.items[0].content.elements[0].text_run.text"); got != "根回复正文" {
t.Fatalf("items[0] text = %q, want 根回复正文", got)
}
secondItem := mustMapValue(t, items[1], "data.items[1]")
if got := mustStringField(t, secondItem, "reply_id", "data.items[1].reply_id"); got != "reply_2" {
t.Fatalf("items[1].reply_id = %q, want reply_2", got)
}
if got := data["count"]; got != float64(2) {
t.Fatalf("count = %#v, want 2", got)
}
if got := data["has_more"]; got != true {
t.Fatalf("has_more = %#v, want true", got)
}
if got := mustStringField(t, data, "page_token", "data.page_token"); got != "next_page" {
t.Fatalf("page_token = %q, want next_page", got)
}
}
func TestDriveListRepliesExecuteViaWikiToBitable(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bitableFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/bitableFromWiki/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"reply_id": "reply_1"}},
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
items := mustSliceValue(t, data["items"], "data.items")
item := mustMapValue(t, items[0], "data.items[0]")
if got := mustStringField(t, item, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
}
}
func TestDriveListRepliesPaginationAndReactionParams(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("page_size"); got != "10" {
t.Errorf("page_size = %q, want 10", got)
}
if got := query.Get("page_token"); got != "cursor_1" {
t.Errorf("page_token = %q, want cursor_1", got)
}
if got := query.Get("need_reaction"); got != "true" {
t.Errorf("need_reaction = %q, want true", got)
}
if got := query.Get("user_id_type"); got != "" {
t.Errorf("user_id_type = %q, want omitted (flag removed)", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "10",
"--page-token", "cursor_1",
"--need-reaction",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}
func TestDriveListRepliesValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "empty comment id",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", " ",
},
wantErr: "--comment-id must not be empty",
wantParam: "--comment-id",
},
{
name: "page size too small",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "0",
},
wantErr: "--page-size must be between 1 and 100",
wantParam: "--page-size",
},
{
name: "page size too large",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "101",
},
wantErr: "--page-size must be between 1 and 100",
wantParam: "--page-size",
},
{
name: "unsupported url type",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
},
wantErr: "replies list supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveListRepliesPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 1069301,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveListRepliesWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveListRepliesDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--need-reaction",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "GET" {
t.Fatalf("api[0].method = %q, want GET", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
params := mustMapValue(t, call["params"], "api[0].params")
if got := params["need_reaction"]; got != true {
t.Fatalf("api[0].params.need_reaction = %#v, want true", got)
}
}
func TestDriveListRepliesDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "GET" {
t.Fatalf("api[1].method = %q, want GET", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
t.Fatalf("api[1].url = %q, want resolved comment ID", got)
}
}
func TestDriveListRepliesOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": ""},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveReactReplyOp = driveCommentOp{
Label: "reply reaction",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
const (
driveReactReplyActionAdd = "add"
driveReactReplyActionDelete = "delete"
)
// driveReactReplyReactionTypes mirrors the reaction_type enum from the
// platform metadata (file.comment.reply.reactions.update_reaction). The
// server does NOT validate this field — an arbitrary string is accepted and
// persisted as a broken reaction on the reply, so this local check is the
// only guard. Values are case-sensitive.
var driveReactReplyReactionTypes = map[string]struct{}{
"ANGRY": {}, "APPLAUSE": {}, "ATTENTION": {}, "AWESOME": {}, "BEAR": {}, "BEER": {},
"BETRAYED": {}, "BIGKISS": {}, "BLACKFACE": {}, "BLUBBER": {}, "BLUSH": {}, "BOMB": {},
"CAKE": {}, "CHUCKLE": {}, "CLAP": {}, "CLEAVER": {}, "COMFORT": {}, "CRAZY": {}, "CRY": {},
"CUCUMBER": {}, "DETERGENT": {}, "DIZZY": {}, "DONE": {}, "DONNOTGO": {}, "DROOL": {},
"DROWSY": {}, "DULL": {}, "DULLSTARE": {}, "EATING": {}, "EMBARRASSED": {}, "ENOUGH": {},
"ERROR": {}, "EYESCLOSED": {}, "FACEPALM": {}, "FINGERHEART": {}, "FISTBUMP": {},
"FOLLOWME": {}, "FROWN": {}, "GIFT": {}, "GLANCE": {}, "GOODJOB": {}, "HAMMER": {},
"HAUGHTY": {}, "HEADSET": {}, "HEART": {}, "HEARTBROKEN": {}, "HIGHFIVE": {}, "HUG": {},
"HUSKY": {}, "INNOCENTSMILE": {}, "JIAYI": {}, "JOYFUL": {}, "KISS": {}, "LAUGH": {},
"LIPS": {}, "LOL": {}, "LOOKDOWN": {}, "LOVE": {}, "MONEY": {}, "MUSCLE": {},
"NOSEPICK": {}, "OBSESSED": {}, "OK": {}, "PARTY": {}, "PETRIFIED": {}, "POOP": {},
"PRAISE": {}, "PROUD": {}, "PUKE": {}, "RAINBOWPUKE": {}, "ROSE": {}, "SALUTE": {},
"SCOWL": {}, "SHAKE": {}, "SHHH": {}, "SHOCKED": {}, "SHOWOFF": {}, "SHY": {}, "SICK": {},
"SILENT": {}, "SKULL": {}, "SLAP": {}, "SLEEP": {}, "SLIGHT": {}, "SMART": {}, "SMILE": {},
"SMIRK": {}, "SMOOCH": {}, "SMUG": {}, "SOB": {}, "SPEECHLESS": {}, "SPITBLOOD": {},
"STRIVE": {}, "SWEAT": {}, "TEARS": {}, "TEASE": {}, "TERROR": {}, "THANKS": {},
"THINKING": {}, "THUMBSUP": {}, "TOASTED": {}, "TONGUE": {}, "TRICK": {}, "UPPERLEFT": {},
"WAIL": {}, "WAVE": {}, "WELLDONE": {}, "WHAT": {}, "WHIMPER": {}, "WINK": {}, "WITTY": {},
"WOW": {}, "WRONGED": {}, "XBLUSH": {}, "YAWN": {}, "YEAH": {}, "FIREWORKS": {}, "BULL": {},
"CALF": {}, "AWESOMEN": {}, "2021": {}, "CANDIEDHAWS": {}, "REDPACKET": {}, "FORTUNE": {},
"LUCK": {}, "FIRECRACKER": {}, "Yes": {}, "No": {}, "Get": {}, "LGTM": {}, "Lemon": {},
"EatingFood": {}, "Hundred": {}, "MinusOne": {}, "ThumbsDown": {}, "Fire": {}, "OKR": {},
"Drumstick": {}, "BubbleTea": {}, "Loudspeaker": {}, "Pin": {}, "Coffee": {}, "Alarm": {},
"Trophy": {}, "Music": {}, "Typing": {}, "Pepper": {}, "CheckMark": {}, "CrossMark": {},
}
type driveReactReplySpec struct {
Ref driveCommentRef
ReplyID string
ReactionType string
Action string
}
func (s driveReactReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"action": s.Action,
"reaction_type": s.ReactionType,
"reply_id": s.ReplyID,
}
}
// DriveReactReply adds or removes an emoji reaction on a comment reply
// through the Drive comment reaction API (POST /drive/v2/files/:file_token/
// comments/reaction), while accepting Wiki URLs/tokens and resolving them to
// the underlying object.
var DriveReactReply = common.Shortcut{
Service: "drive",
Command: "+react-reply",
Description: "Add or remove an emoji reaction on a comment reply for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveReactReplyOp),
common.Flag{Name: "reply-id", Desc: "reply ID to react to (from drive +list-replies); the root reply carries the comment body", Required: true},
common.Flag{Name: "emoji", Desc: "reaction_type value, case-sensitive, e.g. THUMBSUP, HEART, DONE, OK", Required: true},
common.Flag{Name: "action", Desc: "add attaches the reaction; delete removes the current identity's reaction", Required: true, Enum: []string{driveReactReplyActionAdd, driveReactReplyActionDelete}},
),
Tips: []string{
"Reply IDs come from `drive +list-replies` (items[].reply_id); reacting to the root reply reacts to the comment itself.",
"--emoji is case-sensitive and validated locally against the platform reaction_type list (the server accepts and persists arbitrary strings as broken reactions); the full list is in the lark-drive reactions guide.",
"Read reactions back via --need-reaction on `drive +list-replies` / `drive +batch-query-comments`; entries with count=0 are leftovers of removed reactions — filter by count>0.",
"add and delete are idempotent: re-adding an existing reaction or deleting an absent one succeeds without change. delete only cancels the current identity's reaction.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveReactReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveReactReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveReactReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveReactReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveReactReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Reaction %s (%s) on reply %s in %s...\n", spec.Action, spec.ReactionType, spec.ReplyID, common.MaskToken(target.FileToken))
path := fmt.Sprintf("/open-apis/drive/v2/files/%s/comments/reaction", validate.EncodePathSegment(target.FileToken))
if _, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"reply_id": spec.ReplyID,
"reaction_type": spec.ReactionType,
"action": spec.Action,
"updated": true,
}), nil)
return nil
},
}
func readDriveReactReplySpec(runtime *common.RuntimeContext) (driveReactReplySpec, error) {
ref, err := resolveDriveCommentInput(driveReactReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveReactReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if replyID == "" {
return driveReactReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reply-id must not be empty").WithParam("--reply-id")
}
reactionType, err := parseDriveReactReplyEmoji(runtime.Str("emoji"))
if err != nil {
return driveReactReplySpec{}, err
}
action, err := parseDriveReactReplyAction(runtime.Str("action"))
if err != nil {
return driveReactReplySpec{}, err
}
return driveReactReplySpec{
Ref: ref,
ReplyID: replyID,
ReactionType: reactionType,
Action: action,
}, nil
}
// parseDriveReactReplyEmoji validates the reaction_type against the platform
// enum. Case matters: the wire values mix all-caps and CamelCase (THUMBSUP vs
// ThumbsDown), and the server persists any unknown string as a broken
// reaction instead of rejecting it.
func parseDriveReactReplyEmoji(raw string) (string, error) {
emoji := strings.TrimSpace(raw)
if emoji == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji must not be empty").WithParam("--emoji")
}
if _, ok := driveReactReplyReactionTypes[emoji]; !ok {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unknown --emoji %q; reaction_type values are case-sensitive (e.g. THUMBSUP, HEART, DONE, OK) — see the lark-drive reactions guide for the full list",
emoji,
).WithParam("--emoji")
}
return emoji, nil
}
// parseDriveReactReplyAction normalizes and validates the --action value.
// The flag's Enum already rejects unknown values from the CLI, so the error
// branch only guards direct callers.
func parseDriveReactReplyAction(raw string) (string, error) {
action := strings.ToLower(strings.TrimSpace(raw))
if action != driveReactReplyActionAdd && action != driveReactReplyActionDelete {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --action %q; allowed: %s, %s", raw, driveReactReplyActionAdd, driveReactReplyActionDelete).WithParam("--action")
}
return action, nil
}
func buildDriveReactReplyDryRun(spec driveReactReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> update reply reaction").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v2/files/<obj_token from step 1>/comments/reaction").
Desc("[2] Add or remove the reaction on the resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: update reply reaction").
POST("/open-apis/drive/v2/files/:file_token/comments/reaction").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,386 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveReactReplyExecuteDocxAdd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := mustStringField(t, body, "action", "request.action"); got != "add" {
t.Fatalf("request action = %q, want add", got)
}
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "THUMBSUP" {
t.Fatalf("request reaction_type = %q, want THUMBSUP", got)
}
if got := mustStringField(t, body, "reply_id", "request.reply_id"); got != "reply_1" {
t.Fatalf("request reply_id = %q, want reply_1", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_1" {
t.Fatalf("reply_id = %q, want reply_1", got)
}
if got := mustStringField(t, data, "reaction_type", "data.reaction_type"); got != "THUMBSUP" {
t.Fatalf("reaction_type = %q, want THUMBSUP", got)
}
if got := mustStringField(t, data, "action", "data.action"); got != "add" {
t.Fatalf("action = %q, want add", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveReactReplyExecuteViaWikiDelete(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "slidesFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/slidesFromWiki/comments/reaction",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "slides" {
t.Errorf("file_type = %q, want slides", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--token", "wikiResource",
"--type", "wiki",
"--reply-id", "reply_1",
"--emoji", "ThumbsDown",
"--action", "delete",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := mustStringField(t, body, "action", "request.action"); got != "delete" {
t.Fatalf("request action = %q, want delete", got)
}
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "ThumbsDown" {
t.Fatalf("request reaction_type = %q, want ThumbsDown (case preserved)", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "slides" {
t.Fatalf("file_type = %q, want slides", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveReactReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "empty reply id",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", " ",
"--emoji", "THUMBSUP",
"--action", "add",
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "unknown emoji",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "FOOBAR",
"--action", "add",
},
wantErr: `unknown --emoji "FOOBAR"`,
wantParam: "--emoji",
},
{
name: "emoji is case sensitive",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "thumbsup",
"--action", "add",
},
wantErr: `unknown --emoji "thumbsup"`,
wantParam: "--emoji",
},
{
name: "invalid action",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "toggle",
},
wantErr: `invalid value "toggle" for --action`,
wantParam: "--action",
},
{
name: "unsupported url type",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
},
wantErr: "reply reaction supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestParseDriveReactReplyEmoji(t *testing.T) {
t.Parallel()
valid := []string{"THUMBSUP", "ThumbsDown", "Yes", "2021", " HEART "}
for _, in := range valid {
got, err := parseDriveReactReplyEmoji(in)
if err != nil {
t.Fatalf("parseDriveReactReplyEmoji(%q) unexpected error: %v", in, err)
}
if got != strings.TrimSpace(in) {
t.Fatalf("parseDriveReactReplyEmoji(%q) = %q, want %q", in, got, strings.TrimSpace(in))
}
}
for _, in := range []string{"", " ", "YES", "heart", "THUMBS_UP"} {
if _, err := parseDriveReactReplyEmoji(in); err == nil {
t.Fatalf("parseDriveReactReplyEmoji(%q) expected error, got nil", in)
}
}
}
func TestParseDriveReactReplyAction(t *testing.T) {
t.Parallel()
for in, want := range map[string]string{"add": "add", " DELETE ": "delete", "Add": "add"} {
got, err := parseDriveReactReplyAction(in)
if err != nil {
t.Fatalf("parseDriveReactReplyAction(%q) unexpected error: %v", in, err)
}
if got != want {
t.Fatalf("parseDriveReactReplyAction(%q) = %q, want %q", in, got, want)
}
}
if _, err := parseDriveReactReplyAction("toggle"); err == nil {
t.Fatal("parseDriveReactReplyAction(toggle) expected error, got nil")
}
}
func TestDriveReactReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
Body: map[string]interface{}{
"code": 1069301,
"msg": "reply not found",
},
})
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "reply not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
assertDriveCommentAPIError(t, err, 1069301)
}
func TestDriveReactReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveReactReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "HEART",
"--action", "add",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "POST" {
t.Fatalf("api[0].method = %q, want POST", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/drive/v2/files/docxResource/comments/reaction") {
t.Fatalf("api[0].url = %q, want v2 reaction path", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := mustStringField(t, body, "reaction_type", "api[0].body.reaction_type"); got != "HEART" {
t.Fatalf("body.reaction_type = %q, want HEART", got)
}
if got := mustStringField(t, body, "action", "api[0].body.action"); got != "add" {
t.Fatalf("body.action = %q, want add", got)
}
if got := mustStringField(t, body, "reply_id", "api[0].body.reply_id"); got != "reply_1" {
t.Fatalf("body.reply_id = %q, want reply_1", got)
}
}
func TestDriveReactReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--reply-id", "reply_1",
"--emoji", "OK",
"--action", "delete",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
t.Fatalf("api[1].method = %q, want POST", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/reaction") {
t.Fatalf("api[1].url = %q, want reaction path", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
if got := mustStringField(t, body, "action", "api[1].body.action"); got != "delete" {
t.Fatalf("api[1].body.action = %q, want delete", got)
}
}

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveCommentSolvedSpec struct {
Ref driveCommentRef
CommentID string
Solved bool
}
func (s driveCommentSolvedSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"is_solved": s.Solved,
}
}
// driveCommentSolvedConfig parameterizes the two solved-state shortcuts:
// they share one PATCH endpoint whose body is only {is_solved}, so the
// commands differ solely in direction and wording.
type driveCommentSolvedConfig struct {
Command string
Description string
Label string // driveCommentOp label used in unsupported-type errors
Action string // echoed in output and dry-run descriptions
Verb string // progress-line verb
Solved bool
Tip string // direction-specific tip (counterpart pointer)
}
// DriveResolveComment marks a comment solved through the Drive comment patch
// API, while accepting Wiki URLs/tokens and resolving them to the underlying
// object. Reopening is the separate +restore-comment command.
var DriveResolveComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
Command: "+resolve-comment",
Description: "Resolve (mark solved) a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Label: "comment resolve",
Action: "resolve",
Verb: "Resolving",
Solved: true,
Tip: "To reopen a solved comment, use `drive +restore-comment`.",
})
// DriveRestoreComment reopens a solved comment through the same Drive comment
// patch API (is_solved=false).
var DriveRestoreComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
Command: "+restore-comment",
Description: "Restore (reopen) a solved comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Label: "comment restore",
Action: "restore",
Verb: "Restoring",
Solved: false,
Tip: "To mark a comment solved, use `drive +resolve-comment`.",
})
func newDriveCommentSolvedShortcut(cfg driveCommentSolvedConfig) common.Shortcut {
op := driveCommentOp{
Label: cfg.Label,
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
readSpec := func(runtime *common.RuntimeContext) (driveCommentSolvedSpec, error) {
ref, err := resolveDriveCommentInput(op, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveCommentSolvedSpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveCommentSolvedSpec{}, err
}
return driveCommentSolvedSpec{Ref: ref, CommentID: commentID, Solved: cfg.Solved}, nil
}
return common.Shortcut{
Service: "drive",
Command: cfg.Command,
Description: cfg.Description,
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(op),
common.Flag{Name: "comment-id", Desc: fmt.Sprintf("comment ID to %s (from drive +list-comments)", cfg.Action), Required: true},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
cfg.Tip,
"Back-to-back solved-state flips on the same comment can hit server rate limiting (HTTP 429); space out consecutive calls or retry after a short delay.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveCommentSolvedDryRun(cfg, spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, op, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "%s comment %s in %s...\n", cfg.Verb, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
if _, err := runtime.CallAPITyped(
"PATCH",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"action": cfg.Action,
"is_solved": spec.Solved,
"updated": true,
}), nil)
return nil
},
}
}
func buildDriveCommentSolvedDryRun(cfg driveCommentSolvedConfig, spec driveCommentSolvedSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc(fmt.Sprintf("2-step orchestration: resolve wiki -> %s comment", cfg.Action)).
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
PATCH("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id").
Desc(fmt.Sprintf("[2] %s comment (is_solved=%t) on resolved document", cfg.Verb, cfg.Solved)).
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc(fmt.Sprintf("1-step request: %s comment (is_solved=%t)", cfg.Action, cfg.Solved)).
PATCH("/open-apis/drive/v1/files/:file_token/comments/:comment_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,376 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveResolveCommentExecute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := body["is_solved"]; got != true {
t.Fatalf("request is_solved = %#v, want true", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "action", "data.action"); got != "resolve" {
t.Fatalf("action = %q, want resolve", got)
}
if got := data["is_solved"]; got != true {
t.Fatalf("is_solved = %#v, want true", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveRestoreCommentExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_9",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_9",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if got := body["is_solved"]; got != false {
t.Fatalf("request is_solved = %#v, want false", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "action", "data.action"); got != "restore" {
t.Fatalf("action = %q, want restore", got)
}
if got := data["is_solved"]; got != false {
t.Fatalf("is_solved = %#v, want false", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveResolveCommentExecuteWikiResolvesToBitable(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "baseFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/baseFromWiki/comments/comment_3",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_3",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveCommentSolvedValidation(t *testing.T) {
tests := []struct {
name string
shortcut string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
shortcut: "resolve",
args: []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "empty comment id",
shortcut: "resolve",
args: []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", " ",
},
wantErr: "must not be empty",
wantParam: "--comment-id",
},
{
name: "restore rejects unsupported url type",
shortcut: "restore",
args: []string{
"+restore-comment",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
},
wantErr: "comment restore supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
shortcut := DriveResolveComment
if tt.shortcut == "restore" {
shortcut = DriveRestoreComment
}
err := mountAndRunDrive(t, shortcut, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveResolveCommentInputConflict(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--token", "docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveResolveCommentPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
Body: map[string]interface{}{
"code": 1069303,
"msg": "no comment permission",
},
})
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "no comment permission") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveResolveCommentPropagatesWikiResolveError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 230005,
"msg": "wiki node not found",
},
})
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "wiki node not found") {
t.Fatalf("expected wiki resolve error to propagate, got %v", err)
}
}
func TestDriveResolveCommentDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PATCH" {
t.Fatalf("api[1].method = %q, want PATCH", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["is_solved"]; got != true {
t.Fatalf("api[1].body.is_solved = %#v, want true", got)
}
}
func TestDriveRestoreCommentDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--url", "https://example.larksuite.com/sheets/sheetResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "PATCH" {
t.Fatalf("api[0].method = %q, want PATCH", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/sheetResource/comments/comment_1") {
t.Fatalf("api[0].url = %q, want resolved file and comment tokens", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := body["is_solved"]; got != false {
t.Fatalf("api[0].body.is_solved = %#v, want false", got)
}
}
func TestDriveRestoreCommentDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["is_solved"]; got != false {
t.Fatalf("api[1].body.is_solved = %#v, want false", got)
}
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveUpdateReplyOp = driveCommentOp{
Label: "reply update",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveUpdateReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyID string
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
}
func (s driveUpdateReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"content": map[string]interface{}{
"elements": driveReplyV1Elements(s.ReplyElements),
},
}
}
// DriveUpdateReply replaces the content of an existing comment reply through
// the Drive comment reply update API (PUT .../comments/:comment_id/replies/:reply_id),
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveUpdateReply = common.Shortcut{
Service: "drive",
Command: "+update-reply",
Description: "Update the content of a comment reply on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveUpdateReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID that owns the reply (from drive +list-comments)", Required: true},
common.Flag{Name: "reply-id", Desc: "reply ID to update (from drive +list-replies)", Required: true},
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
),
Tips: []string{
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
"The update replaces the whole reply content; there is no partial edit.",
"Reply IDs come from `drive +list-replies` (items[].reply_id); updating a comment's root reply rewrites the comment body itself.",
"Only the identity that created a reply can update it; other identities get API error 1069303 (forbidden). Use the same --as identity that created the reply.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveUpdateReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveUpdateReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveUpdateReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveUpdateReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveUpdateReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Updating reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
validate.EncodePathSegment(spec.ReplyID),
)
if _, err := runtime.CallAPITyped(
"PUT",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"reply_id": spec.ReplyID,
"updated": true,
}), nil)
return nil
},
}
func readDriveUpdateReplySpec(runtime *common.RuntimeContext) (driveUpdateReplySpec, error) {
ref, err := resolveDriveCommentInput(driveUpdateReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveUpdateReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveUpdateReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
return driveUpdateReplySpec{}, err
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return driveUpdateReplySpec{}, err
}
return driveUpdateReplySpec{
Ref: ref,
CommentID: commentID,
ReplyID: replyID,
ReplyElements: replyElements,
}, nil
}
func buildDriveUpdateReplyDryRun(spec driveUpdateReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> update comment reply").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
PUT("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
Desc("[2] Update reply content on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: update comment reply").
PUT("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveUpdateReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"更新后的回复"},{"type":"mention_user","mention_user":"ou_123"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
content := mustMapValue(t, body["content"], "request.content")
elements := mustSliceValue(t, content["elements"], "request.content.elements")
if len(elements) != 2 {
t.Fatalf("len(request.content.elements) = %d, want 2", len(elements))
}
first := mustMapValue(t, elements[0], "request.content.elements[0]")
if got := mustStringField(t, first, "type", "request.content.elements[0].type"); got != "text_run" {
t.Fatalf("request element type = %q, want text_run", got)
}
firstText := mustMapValue(t, first["text_run"], "request.content.elements[0].text_run")
if got := mustStringField(t, firstText, "text", "request.content.elements[0].text_run.text"); got != "更新后的回复" {
t.Fatalf("text_run.text = %q, want 更新后的回复", got)
}
second := mustMapValue(t, elements[1], "request.content.elements[1]")
if got := mustStringField(t, second, "type", "request.content.elements[1].type"); got != "person" {
t.Fatalf("request element type = %q, want person", got)
}
secondPerson := mustMapValue(t, second["person"], "request.content.elements[1].person")
if got := mustStringField(t, secondPerson, "user_id", "request.content.elements[1].person.user_id"); got != "ou_123" {
t.Fatalf("person.user_id = %q, want ou_123", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
t.Fatalf("reply_id = %q, want reply_2", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveUpdateReplyExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "sheetFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "sheet" {
t.Errorf("file_type = %q, want sheet", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"updated from wiki"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
t.Fatalf("file_type = %q, want sheet", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
// The reply-update endpoint does not declare the 100-element cap (only
// reply-create does), so +update-reply must NOT reject >100 elements locally.
func TestDriveUpdateReplyDoesNotCapElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", "[" + strings.Join(elems, ",") + "]",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("+update-reply must not cap element count locally, got %v", err)
}
}
func TestDriveUpdateReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "unsafe reply id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "../reply",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "path traversal",
wantParam: "--reply-id",
},
{
name: "empty reply id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", " ",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "invalid content json",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `not-json`,
},
wantErr: "--content is not valid JSON",
wantParam: "--content",
},
{
name: "unsupported url type",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "reply update supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveUpdateReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
Body: map[string]interface{}{
"code": 1069307,
"msg": "no permission to edit reply",
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "no permission to edit reply") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveUpdateReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_token": "tokenOnly"},
},
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveUpdateReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"更新后的回复"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "PUT" {
t.Fatalf("api[0].method = %q, want PUT", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
content := mustMapValue(t, body["content"], "api[0].body.content")
elements := mustSliceValue(t, content["elements"], "api[0].body.content.elements")
if len(elements) != 1 {
t.Fatalf("api[0].body.content.elements length = %d, want 1", len(elements))
}
}
func TestDriveUpdateReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PUT" {
t.Fatalf("api[1].method = %q, want PUT", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
}
}

View File

@@ -11,11 +11,20 @@ func Shortcuts() []common.Shortcut {
DriveUpload,
DriveCreateFolder,
DriveCreateShortcut,
DriveCopy,
DriveDownload,
DrivePreview,
DriveCover,
DriveAddComment,
DriveListComments,
DriveBatchQueryComments,
DriveResolveComment,
DriveRestoreComment,
DriveAddReply,
DriveListReplies,
DriveUpdateReply,
DriveDeleteReply,
DriveReactReply,
DriveExport,
DriveExportDownload,
DriveImport,

View File

@@ -18,11 +18,20 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+upload",
"+create-folder",
"+create-shortcut",
"+copy",
"+download",
"+preview",
"+cover",
"+add-comment",
"+list-comments",
"+batch-query-comments",
"+resolve-comment",
"+restore-comment",
"+add-reply",
"+list-replies",
"+update-reply",
"+delete-reply",
"+react-reply",
"+export",
"+export-download",
"+import",

View File

@@ -38,6 +38,7 @@ metadata:
进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`
- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "<url>" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。
- Base/Wiki URL 的 `table=` query 参数实际表示当前选中的顶层 block可能是数据表、仪表盘或 workflow不要按参数名自行当成 `table_id`。以 `+url-resolve` 返回的 `block_type` 以及 `table_id` / `dashboard_id` / `workflow_id` 为准;`selection_source=url_query` 只说明 URL 当前选中了该 block不代表它覆盖用户明确点名的目标。若用户点名的 dashboard 与 `block_name` 不一致,先用 `+dashboard-list` 按名称匹配;若只返回中性 `block_id`,按 hint 用 `+base-block-list` 确认类型。
- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "<keyword>" --as user``--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。
- 文档嵌入 Base 标签:直接读取 `<bitable>` / `<base_refer>``token` 作为 `--base-token``table-id` 作为 `--table-id``view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`
- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base用户要新建时才用 `+base-create`

View File

@@ -79,16 +79,23 @@ lark-cli base +data-query \
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--dsl <json>` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
## 如何从链接中提取参数
## 如何从链接中解析参数
用户通常会提供如下 URL
```
https://example.feishu.cn/base/<base_token>?table=<table_id>
```text
https://example.feishu.cn/base/<base_token>?table=<block_id>
```
- `--base-token`:取 `/base/` 后面的字符串
- DSL 中的 `tableId`:取 `table=` 后面的值
不要直接把 URL 中的 `table=` 当成数据表 ID。它表示当前选中的 Base 顶层块,可能是数据表、仪表盘、工作流、文件夹或文档。先解析链接:
```bash
lark-cli base +url-resolve --url "<url>" --as user
```
- `--base-token`:使用返回的 `base_token`
- 仅当返回的 `block_type``table`DSL 中的 `tableId` 才使用返回的 `table_id`
- 如果返回的是其他块类型,按 `hint.next_step` 继续处理;如果只返回中性的 `block_id`,先用 `+base-block-list` 确认块类型,再选择实际要查询的数据表
## API 入参详情

View File

@@ -96,12 +96,12 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
## 前置条件路由
> **先判断是否重复性日程**:若操作对象是重复性日程,必须先读 [重复性日程操作规范](references/lark-calendar-recurring.md),并在用户未明确范围时先确认「仅此次/全部/此次及后续」(不要默认仅此次),再按下表进入具体操作流程。
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 写操作反馈

View File

@@ -76,6 +76,7 @@
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 「给明天的日程加个会议室」(且为重复日程) | 范围不明确,**必须询问用户** |
| 未明确范围 | **必须询问用户** |
## 注意事项

View File

@@ -1,7 +1,7 @@
---
name: lark-contact
version: 1.0.0
description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人 / 智能体(agent)。当用户提到一个名字要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
metadata:
requires:
bins: ["lark-cli"]
@@ -15,12 +15,19 @@ metadata:
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |
| 按关键词搜索当前用户可见的机器人 / 智能体 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 |
| 已知 open_id 取他人资料 | `+search-user --user-ids <id>` | [`+get-user --user-id <id>`](references/lark-contact-get-user.md) |
| 查看自己 | `+get-user``+search-user --user-ids me` | 不支持 |
| 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 |
已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。
### 名字没说清是人还是机器人 / 智能体
用户给的名字常常不表明类型。例如「和 reviewDuck 约个会」里的 reviewDuck 可能是同事昵称,也可能是机器人。
- 名字含 bot / agent / AI / 助手 / 机器人 / 智能体 / assistant 等明显特征时,反过来先搜机器人更快
- 不确定的话两边都搜一下
## 典型场景
找张三给他发消息:先搜,确认 open_id,再发:
@@ -42,11 +49,20 @@ lark-cli contact user_profiles batch_query \
搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。
## 搜索机器人 / 智能体
`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节等见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。
```bash
lark-cli contact +search-bot --query '会议助手' --as user
lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user
```
## 注意事项
- **41050 / Permission denied** 受当前身份的可见范围限制(条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。
- **41050 / Permission denied** 受当前身份的可见范围限制(条命令都可能遇到)。细节见 [`lark-shared`](../lark-shared/SKILL.md)。
- **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。
- **ID 类型**:默认 `open_id``+get-user` `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`
- **ID 类型**:`+get-user`通过 `--user-id-type` 使用 `open_id``union_id``user_id`;`+search-user` 使用用户 open_id;`+search-bot` 不支持按 ID 查询,它按关键词搜索并返回机器人 open_id
## 不在本 skill 范围

View File

@@ -0,0 +1,60 @@
# +search-bot
按关键词搜索当前用户可见的机器人。仅支持 user 身份,需要 `search:bot` 权限。
- ✅ 用关键词搜索机器人并获取 open_id
- ✅ 一次搜索多个关键词(`--queries`)
- ✅ 在指定群范围内搜索机器人(`--chat-ids`)
## 参数
必须传 `--query``--queries``--chat-ids` 指定搜索范围,`--has-chatted` 筛选已聊过的机器人;两者都不能单独使用。
| Flag | 说明 |
|---|---|
| `--query <text>` | 搜索一个关键词,最多 50 个字符 |
| `--queries <csv>` | 并行搜索多个关键词,最多 20 个;每个最多 50 个字符。不能和 `--query` 一起使用 |
| `--chat-ids <csv>` | 只在指定群内搜索,最多 100 个群;支持群 ID 或群链接 |
| `--has-chatted` | 只返回聊过天的机器人;不需要时不要传此参数 |
| `--page-size <n>` | 返回条数,130,默认 20 |
```bash
lark-cli contact +search-bot --query '会议助手' --as user
lark-cli contact +search-bot --query '助手' --has-chatted --as user
lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user
```
## 输出
| 字段 | 类型 | 说明 | 空值时 |
|---|---|---|---|
| `open_id` | string | 机器人 ID | 始终非空 |
| `name` | string | 机器人名称 | 空字符串 |
| `description` | string | 机器人简介 | 字段省略 |
| `chat_id` | string | 与机器人的单聊 ID | 空字符串 |
| `enable_join_group` | bool | 是否允许加入群聊 | — |
| `is_agent` | bool | 是否是智能体 | — |
| `tenant_id` | string | 租户标识 | 字段省略 |
| `match_segments` | string[] | 命中的文本片段 | 无命中时为 `[]` |
### 没有分页
不支持分页。`has_more=true` 时改用更具体的关键词,或调整搜索范围。
### 多条命中怎么选
命中多个机器人时,结合 `description``is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。
```bash
lark-cli contact +search-bot --query '会议助手' \
--jq '.data.bots[] | select((.description // "") | contains("<功能关键词>"))' --as user
```
## fanout(`--queries`)
输出为 `{bots[], queries[], notice?}``has_more` 只出现在每个关键词的结果中。
- `bots[].matched_query`:该结果对应的关键词
- `queries[]`:每个关键词的执行结果,格式为 `{query, error?, has_more, notice?}`
- 部分关键词失败时保留其他结果;全部失败时命令报错
- `--chat-ids``--has-chatted` 对所有关键词生效

View File

@@ -16,12 +16,12 @@ metadata:
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base``lark-base` 只负责导入完成后的表内操作。
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive +copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
## 快速决策
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。这是会改变文档归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token`lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive +copy`,用法见 [`references/lark-drive-copy.md`](references/lark-drive-copy.md);如果是 wiki URL/token使`wiki +node-copy`,见 [`lark-wiki-node-copy.md`](../lark-wiki/references/lark-wiki-node-copy.md)
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
@@ -32,9 +32,7 @@ metadata:
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps``file_type=apps`),裸 token 调 shortcut 时传 `--type apps`
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户要**文档评论**做任何操作(添加评论、列表 / 批量查询、回复、获取 / 更新 / 删除回复、解决 / 恢复、reaction按下方 Shortcuts 表选择对应的 `drive +<verb>` 评论命令,执行前先阅读该命令的 ref。按评论定位文档正文位置见 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md)。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
@@ -86,28 +84,8 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id <table-id>!<record-id>!<view-id>` |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`wiki URL/token 会自动解析到底层真实 token/type妙搭 apps URL 使用 `/page/<token>` |
### 评论能力入口
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md)review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数。
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`
- `drive +add-comment``--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id``anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis``--full-comment`
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<``>`;提交前必须先转义:`<` -> `&lt;``>` -> `&gt;`
- 使用 `drive +add-comment`shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2``drive file.comment.replys create``drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。
- Base 记录局部评论使用 `--type bitable` / `--type base``/base/``/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable``base` 仅作为兼容别名兜底。
- Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file tokenbase token+ `--block-id <table-id>!<record-id>!<view-id>`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图不影响评论挂载点只要在同一记录上都能看到评论但必须传否则通知无法确定跳转视图。ID 可通过 [`lark-base`](../lark-base/SKILL.md) 获取。
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`docx/sheet/slides 局部评论传 `anchor.block_id`Base 记录局部评论传 `anchor.block_id`table_id`anchor.base_record_id``anchor.base_view_id`
- 直接调用原生 `drive.file.comments.*` / `drive.file.comment.replys.*` 评论 Base 文档时,`file_type``bitable`,不要填 `base`
### 典型错误与解决方案
@@ -150,8 +128,17 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| `+sync` | 双向同步本地目录与 Drive 文件夹:拉取 `new_remote`、推送 `new_local``modified``--on-conflict=remote-wins\|local-wins\|keep-both\|ask` 处理;`--quick` 用修改时间近似比较;`--on-duplicate-remote` 支持 `fail` / `newest` / `oldest`;只同步 `type=file`,跳过在线文档和 shortcut且不会删除两端多余文件。 |
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable)/apps 评论列表;优先传 URL支持 wiki 自动解包和妙搭 `/page/<token>` URL。 |
| [`+copy`](references/lark-drive-copy.md) | 复制 doc/docx/sheet/file/mindnote/slides/base(bitable) 到目标文件夹;支持 URL 传参wiki 输入会引导改用 `wiki +node-copy`。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | doc/docx/file/sheet/slides/base(bitable) 添加全文/局部评论;不支持妙搭 apps。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 分页获取评论列表。 |
| [`+batch-query-comments`](references/lark-drive-batch-query-comments.md) | 按评论 ID 批量获取评论。 |
| [`+resolve-comment`](references/lark-drive-resolve-comment.md) | 把评论标记为已解决(`is_solved=true`)。 |
| [`+restore-comment`](references/lark-drive-restore-comment.md) | 恢复/重新打开已解决评论(`is_solved=false`)。 |
| [`+add-reply`](references/lark-drive-add-reply.md) | 给已有评论添加回复。 |
| [`+list-replies`](references/lark-drive-list-replies.md) | 分页获取某条评论下的回复。 |
| [`+update-reply`](references/lark-drive-update-reply.md) | 整体替换某条回复的内容。 |
| [`+delete-reply`](references/lark-drive-delete-reply.md) | 删除评论下的某条回复(高风险,需 `--yes`)。 |
| [`+react-reply`](references/lark-drive-react-reply.md) | 给回复加/删表情回应。 |
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
@@ -170,6 +157,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
## API Resources
```bash
@@ -183,25 +171,11 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### files
- `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制
- `copy` — 复制文件;优先使用 [`drive +copy`](references/lark-drive-copy.md) shortcut
- `create_folder` — 新建文件夹
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
- `patch` — 修改文件标题
### file.comments
- `batch_query` — 批量获取评论
- `create_v2` — 添加全文/局部(划词)评论
- `list` — 分页获取文档评论
- `patch` — 解决/恢复 评论
### file.comment.replys
- `create` — 添加回复
- `delete` — 删除回复
- `list` — 获取回复
- `update` — 更新回复
### permission.members
- `auth`
@@ -230,7 +204,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### file.comment.reply.reactions
- `update_reaction` — 添加/删除 reaction
- `update_reaction` — 添加/删除 reaction;优先使用 `drive +react-reply`
### quota_details

View File

@@ -159,6 +159,7 @@ lark-cli drive +add-comment \
## 行为说明
- **不支持妙搭 apps**:妙搭不支持新增评论,`--doc``/page/<token>` URL 或 `--type apps` 都不可用。其余评论管理命令(列表、批量查询、回复、解决/恢复、reaction都支持 apps。
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。
@@ -174,10 +175,7 @@ lark-cli drive +add-comment \
- `<img id="bPk" ... />` 对应 `--block-id img!bPk`,表示给图片元素评论。
- `<shape type="text" id="bPq">...</shape>` 对应 `--block-id shape!bPq`,表示给文本 shape 评论。
- `--content` 接收结构化评论元素数组`type` 支持 `text``mention_user``link`。为便于书写,`mention_user` / `link` 元素可以直接把用户 ID 或链接地址放在 `text` 字段中shortcut 会转换成 OpenAPI 所需字段
- `type=text` 的评论文本不能直接包含 `<``>`;应优先传 `&lt;``&gt;`。shortcut 在发送前也会自动将 `<``>` 转义为 `&lt;``&gt;` 作为兜底。
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
- `--content` 结构化评论元素数组`text` / `mention_user` / `link`),完整格式见 [`lark-drive-comment-content.md`](lark-drive-comment-content.md);上方示例已覆盖常见写法
- 写入评论前会自动生成符合 OpenAPI 定义的请求体shortcut 用户只需要传 `--doc``--content`,局部评论再传对应格式的 `--block-id`
- `--dry-run` 仅预览调用链和请求体,不会实际写入。
- 如果需要更底层的控制,仍可改用 `lark-cli schema drive.file.comments.create_v2` + `lark-cli drive file.comments create_v2`

View File

@@ -0,0 +1,47 @@
# drive +add-reply
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理;`--content` 完整格式见 [`lark-drive-comment-content.md`](lark-drive-comment-content.md)。
给已有评论添加一条回复。
## 命令
```bash
# 推荐:完整 URL + 目标评论 ID + 回复内容
lark-cli drive +add-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>' --content '[{"type":"text","text":"回复内容"}]'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 要回复的评论 ID来自 `drive +list-comments``items[].comment_id` |
| `--content` | 是 | `reply_elements` JSON`type=text` 文本自动转义;完整 schema、mention_user/link、10000 字符限制见 [`lark-drive-comment-content.md`](lark-drive-comment-content.md) |
## 回复限制
- `is_whole=true` 的全文评论、`is_solved=true` 的已解决评论都不能回复。
- 目标的 `is_whole` / `is_solved` 通常在上一步 `+list-comments` / `+batch-query-comments` 的结果里已有,据此判断即可;信息不足时再补查一次。
- 补查时注意 `+list-comments` 默认只返回未解决评论:要核对某条评论是否已被解决,需要带 `--solved-status all`,否则已解决评论根本不出现在结果里,看起来像评论不存在。
- 命中限制时如实提示(“全文评论不支持回复” / “该评论已被解决,无法回复”),不要自动替用户改回复到别的评论。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"created": true,
"reply_id": "<reply_id>"
}
```
## 参考
- [lark-drive-comment-content](lark-drive-comment-content.md) -- `--content` 格式
- [lark-drive-batch-query-comments](lark-drive-batch-query-comments.md) -- 按 ID 查 is_whole/is_solved
- [lark-drive-list-replies](lark-drive-list-replies.md) -- 获取回复

View File

@@ -0,0 +1,46 @@
# drive +batch-query-comments
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
按评论 ID 批量获取评论卡片。已知 comment_id 时用它精确取;要分页遍历、全量统计或找最新/最早评论,用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)。
## 命令
```bash
# 推荐:完整 URL + 评论 ID逗号分隔或重复 --comment-ids单次上限 100
lark-cli drive +batch-query-comments --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-ids '<id1>,<id2>'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-ids` | 是 | 评论 ID逗号分隔或重复传单次最多 100 个;来自 `drive +list-comments``items[].comment_id` |
| `--need-reaction` | 否 | 返回评论卡片上的 reaction 数据,见 [`lark-drive-reactions.md`](lark-drive-reactions.md) |
| `--need-relation` | 否 | docx 评论定位关系;仅 docx 生效,非 docx 静默忽略,见 [`lark-drive-comment-location.md`](lark-drive-comment-location.md) |
## 行为说明
- `--need-relation` 通过请求 **body** 发送(`+list-comments` 是 query param只在解析后的目标是 docx 时发送;该参数未收录于平台 metadata但服务端支持返回 `items[].relation` 及块位置。
- 输出的 `items` 始终是 JSON 数组(服务端省略时归一化为 `[]`),外层补 `file_token``file_type``count`
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"items": [],
"count": 0
}
```
`items` 是命中的评论卡片数组(外层补 `file_token`/`file_type`wiki 输入再加 `wiki_token``count` 是命中数。
## 参考
- [lark-drive-list-comments](lark-drive-list-comments.md) -- 分页获取评论列表
- [lark-drive-comment-location](lark-drive-comment-location.md) -- `need_relation` 评论定位

View File

@@ -0,0 +1,50 @@
# Drive 评论内容格式(--content
> 本文是写入类评论命令(`+add-comment` / `+add-reply` / `+update-reply`)共享的 `--content` 内容格式说明,由这三个命令的 ref 引用。
`drive +add-comment``drive +add-reply``drive +update-reply``--content` 使用同一套 `reply_elements` JSON 数组格式。本文集中说明 schema、元素类型、转义和长度限制各命令 ref 只保留最常见的纯文本例子。
## Schema
`--content` 是一个 JSON 数组字符串,至少一个元素。每个元素按 `type` 用对应字段承载值:
| type | 字段 | 值 |
|---|---|---|
| `text` | `text` | 普通文本正文 |
| `mention_user` | `mention_user` | 被 @ 用户的 open_id |
| `link` | `link` | 飞书云文档链接docx/doc/sheet/bitable/wiki 等云文档 URL对应 wire `docs_link` |
最常见就是单个纯文本元素:
```bash
--content '[{"type":"text","text":"评论正文"}]'
```
组合多种元素:
```bash
--content '[
{"type":"text","text":"请 "},
{"type":"mention_user","mention_user":"ou_xxx"},
{"type":"text","text":" 看下 "},
{"type":"link","link":"https://your-tenant.feishu.cn/docx/<TOKEN>"}
]'
```
- `type=text``text` 不能为空;未知 `type` 会被拒绝,只允许 `text` / `mention_user` / `link`
- 为省事,`mention_user` / `link` 的值也可以直接放在 `text` 字段(如 `{"type":"mention_user","text":"ou_xxx"}`CLI 会识别;推荐用上表的专属字段,语义更清晰。
- `link` 是**飞书云文档链接**wire 类型就叫 `docs_link`),不是任意网页链接。回复类命令(`+add-reply` / `+update-reply`)会校验,传外部 URL 被服务端拒绝(`1069302`),只接受飞书云文档 URL`+add-comment` 对外部 URL 较宽松(能写入),但外部链接未必按云文档链接渲染,仍建议只放云文档 URL。
## 长度限制
- 所有 `type=text` 元素的字符rune总和上限 10000按原始输入的字符数计中英文、符号一视同仁不是字节数、也不是转义后的长度
- 这是对**总额**的限制:把一段长文本拆成多个 text 元素不能绕过,它们共用同一个 10000 字符预算。
- `mention_user` / `link` 不计入该长度。
- 超限时 shortcut 在发送前拒绝并指出累计超长的元素;服务端对超限返回不透明的 `[1069302]`,所以这是预检。
## 参考
- [lark-drive-add-comment](lark-drive-add-comment.md) -- 添加评论
- [lark-drive-add-reply](lark-drive-add-reply.md) -- 回复评论
- [lark-drive-update-reply](lark-drive-update-reply.md) -- 更新回复

View File

@@ -1,11 +1,12 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置。
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置;已知评论 ID 时用 `drive +batch-query-comments --need-relation`
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- `drive +list-comments` `drive +batch-query-comments`会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- 注意参数位置差异list 的 `need_relation` 在 query paramsbatch_query 的在请求 body直接调 raw OpenAPI 时才需要关心;两个 shortcut 已各自处理)。
## 调用方式
@@ -21,20 +22,13 @@ lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
```
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params
已知评论 ID 时,用 `drive +batch-query-comments --need-relation` 直接按 ID 取
```bash
lark-cli drive file.comments list \
--params '{"file_token":"<doc_token>","file_type":"docx","is_solved":false,"need_relation":true}'
lark-cli drive +batch-query-comments --url '<docx_or_wiki_url>' --comment-ids '<comment_id>' --need-relation
```
已知评论 ID 批量查询时,把 `need_relation` 放在请求体里:
```bash
lark-cli drive file.comments batch_query \
--params '{"file_token":"<doc_token>","file_type":"docx"}' \
--data '{"comment_ids":["<comment_id>"],"need_relation":true}'
```
只有在需要 shortcut 未暴露的底层参数时,才直接调 raw OpenAPI两个 shortcut 已各自处理 `need_relation` 的位置差异list 在 query paramsbatch_query 在请求 body
同时获取文档内容,并要求返回 block id
@@ -138,7 +132,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,`drive file.comments batch_query` 并带 `need_relation=true`。raw `drive file.comments list` 仅作为低层参数兜底。
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,用 `drive +batch-query-comments --need-relation`。原生 `drive file.comments list/batch_query` 仅在需要 shortcut 未暴露的底层参数兜底。
3.`docs +fetch --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。

View File

@@ -1,80 +0,0 @@
# Drive 评论查询、统计与回复指南
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
## 评论模式
- `drive +add-comment` 支持全文评论和局部评论。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终解析为 `doc` / `docx` / `file` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id`sheet` 支持 `<sheetId>!<cell>``slides` 支持 `<slide-block-type>!<xml-id>`wiki URL 解析到这些类型时也支持对应局部评论。
- Drive file 只支持全文评论,不支持局部评论。支持扩展名:`.md``.txt``.json``.csv``.go``.js``.py``.pptx``.png``.jpg``.jpeg``.zip``.mp3``.mp4``.pdf``.docx``.xlsx` 等未在白名单内的普通文件暂不支持。
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论。
- `drive +add-comment``--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id``anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis``--full-comment`
- 评论写入内容里的文本不能直接出现 `<``>`;提交前应转义为 `&lt;``&gt;``drive +add-comment` 会对 `type=text` 文本元素自动兜底转义;直接调用原生评论 API 时需要自行转义。
- 如果 wiki 解析后不是 `doc` / `docx` / `file` / `sheet` / `slides`,不要用 `+add-comment`
## 查询默认口径
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`。只查已解决评论时传 `--solved-status true`
```bash
# 默认查询:仅未解决评论
lark-cli drive +list-comments --url '<DOC_URL>'
# 全部评论:包含已解决和未解决
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
# 已解决评论
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
# 裸 wiki token
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
```
## 评论卡片与统计
- `drive file.comments list` 返回的 `items` 是评论卡片列表,每个 `item` 对应用户界面中的一张评论卡片,不是平铺的互动消息列表。
- 创建第一条评论时会同时创建该卡片里的第一条 reply真正承载正文的是 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的“评论本身”。
- 统计“评论数”或“评论卡片数”:统计 `items` 长度;全量统计时对所有分页返回的 `items` 长度累加。
- 统计“回复数”:统计所有 `item.reply_list.replies` 长度之和,再减去 `items` 长度。
- 统计“总互动数”:统计所有 `item.reply_list.replies` 长度之和,包含每张评论卡片里的首条评论。
- 如果 `item.has_more=true`,说明该评论卡片下还有更多回复未包含在当前返回中;需要继续调用 `drive file.comment.replys list` 拉全后,再做全量回复数或总互动数统计。
## 排序
- 只有当用户明确提到“最新评论”“最后评论”“最早评论”时,才需要按 `create_time` 排序。
- 排序前必须拉完所有评论分页,不能只取第一页。
- “最新评论”/“最后评论”:按 `create_time` 降序取第一条。
- “最早评论”:按 `create_time` 升序取第一条。
- 用户只说“第一条评论”时,直接使用 `drive file.comments list` 返回的第一条,不需要额外排序。
## 回复限制
- 回复前先检查目标评论状态。
- `is_whole=true` 的全文评论不支持回复;遇到时提示“全文评论不支持回复”。
- `is_solved=true` 的已解决评论不支持回复;遇到时提示“该评论已被解决,无法回复”。
- 当目标评论不能回复时,只提示限制,不要自动替用户寻找其他可回复评论。
## batch_query 与 list
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题。
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
## 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略。
## 原生 API
需要更底层地直接调用评论 V2 协议时,先查看 schema再调用原生命令。全文评论省略 `anchor`,局部评论传 `anchor.block_id`
```bash
lark-cli schema drive.file.comments.create_v2
lark-cli drive file.comments create_v2 \
--params '{"file_token":"<DOC_TOKEN>"}' \
--data '{"file_type":"docx","reply_elements":[{"type":"text","text":"全文评论内容"}]}'
```

View File

@@ -0,0 +1,62 @@
# drive +copy
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
复制一个 Drive 文件(在线文档、表格、多维表格、幻灯片、思维笔记或普通文件)到目标文件夹,生成一个内容相同的新副本。推荐直接传文档 URL。
## 命令
```bash
# 推荐:源文档传 URL自动识别类型和 token
lark-cli drive +copy --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --name '副本名称' --folder-token <TARGET_FOLDER_TOKEN>
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 源文档 URL支持 `doc` / `docx` / `sheet` / `file` / `mindnote` / `slides` / `base` / `bitable` 路径 |
| `--token` | 与 `--url` 二选一 | 源文档 token 或 URL裸 token 必须配合 `--type` |
| `--type` | 裸 token 时必填 | 源文件类型:`doc``docx``sheet``file``mindnote``slides``bitable``base` 为兼容别名);传 URL 时可省略,显式传入时必须与 URL 类型一致 |
| `--name` | 是 | 副本名称,最长 256 字节 |
| `--folder-token` | 是 | 目标文件夹 token、文件夹 URL或常量 `my_space`(复制到当前身份"我的空间"根目录,内部自动解析根 token |
| `--extra` | 否 | 可重复的 `key=value` 对,原样透传给 API 的 `extra` 自定义复制参数;典型用法 `--extra target_type=docx`(复制旧版 doc 时转换为 docx 副本) |
## 输入规则
- `--url``--token` 互斥,只传一个
- `--type` 必须与源文件真实类型一致,类型不匹配时服务端会返回失败
- `base``bitable` 是同一概念CLI 会把 `base` 归一化为 `bitable` 后发给服务端
- 目标文件夹必须是云空间(云盘/云存储)文件夹 token不能传 wiki 节点 token
## Wiki 场景
`drive +copy` 只复制云盘Drive文件不接受 wiki URL / token传入时返回校验错误错误 hint 会给出替代命令。知识库内复制节点用 [`lark-wiki`](../../lark-wiki/SKILL.md) 的 `wiki +node-copy`;要把 wiki 文档复制成 Drive 空间里的独立副本(脱离知识库),先用 `drive +inspect` 解包拿到底层 `token``type`,再对底层 token 执行 `drive +copy`
## 行为说明
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto``--format``--jq``--dry-run` 使用
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建副本
- 这是写入操作;执行前应确认源文档和目标文件夹准确无误
## 输出
```json
{
"copied": true,
"file_token": "<new_file_token>",
"file_type": "docx",
"name": "副本名称",
"url": "https://example.larksuite.com/docx/<new_file_token>",
"source_file_token": "<source_file_token>",
"source_type": "docx",
"folder_token": "<target_folder_token>"
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-wiki](../../lark-wiki/SKILL.md) -- 知识库节点复制(`wiki +node-copy`
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,48 @@
# drive +delete-reply
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
删除某条回复。**高风险写操作**:真实执行需要按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 的高风险审批协议向用户确认后追加 `--yes`;删除不可恢复。
## 命令
```bash
# 先预览(--dry-run 不需要 --yes
lark-cli drive +delete-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>' --reply-id '<id>' --dry-run
# 确认后真实删除(把 --dry-run 换成 --yes
lark-cli drive +delete-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>' --reply-id '<id>' --yes
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 回复所属的评论 ID来自 `drive +list-comments` |
| `--reply-id` | 是 | 要删除的回复 ID来自 `drive +list-replies``items[].reply_id`,或 `drive +list-comments``items[].reply_list.replies[].reply_id` |
| `--yes` | 真实执行时是 | 高风险确认;`--dry-run` 预览不需要 |
## 行为说明
- 删除永久生效,回复没有回收站或撤销。
- 删除按 reply 逐条生效:删除某条回复(包括第一条/根回复)不影响其它回复;把该评论卡片下的所有回复都删完后,评论卡片在前端页面才不再显示。
- **删除整条评论没有专门的命令,需要用本命令删光该卡片下的所有回复**(先用 `drive +list-replies` 拉全回复 id。删除前先和用户确认删的是某条回复还是整条评论。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"reply_id": "<reply_id>",
"deleted": true
}
```
## 参考
- [lark-drive-list-replies](lark-drive-list-replies.md) -- 获取回复与 reply_id

View File

@@ -14,72 +14,10 @@
```bash
# 推荐:直接传用户给出的完整 URL。默认只查未解决评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>"
lark-cli drive +list-comments --url "<DOCUMENT_URL>"
# 只有用户明确要求包含已解决评论时,才查询已解决和未解决的全部评论
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--solved-status all
# 查询已解决评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--solved-status true
# 只查全文评论或局部评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--comment-scope whole
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--comment-scope partial
# 电子表格 URL 保留 /sheets/ 路径,直接原样传入;不要把 sheet token 拼成 /docx/<token>。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/sheets/<SHEET_TOKEN>"
# 妙搭 apps URL 使用 /page/<token>shortcut 会识别为 file_type=apps。
lark-cli drive +list-comments \
--url "https://example.feishu.cn/page/<APPS_TOKEN>/"
# wiki URL 会自动解包。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
lark-cli drive +list-comments \
--token "<WIKI_TOKEN>" \
--type wiki
# 裸 token 需要声明 token 对应类型;不要默认当作 docx。这里以 sheet 为例。
lark-cli drive +list-comments \
--token "<DOCUMENT_TOKEN>" \
--type sheet \
--page-size 100
# 妙搭裸 apps token 需要显式声明 --type apps。
lark-cli drive +list-comments \
--token "<APPS_TOKEN>" \
--type apps
# docx 需要评论定位关系时再带 need-relation非 docx 会静默忽略。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--need-relation
# 分页续跑。
# 先看上一页输出的 has_more只有 has_more=true 时,才用返回的 page_token 继续。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--page-size 100 \
--page-token "<NEXT_PAGE_TOKEN>"
# 预览请求链路,不发真实请求。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--dry-run
# 只有用户明确要求包含已解决评论时,才传 --solved-status all
lark-cli drive +list-comments --url "<DOCUMENT_URL>" --solved-status all
```
## 参数
@@ -103,7 +41,26 @@ lark-cli drive +list-comments \
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突shortcut 会返回 validation error建议移除 `--type`
- wiki 输入会自动解析到真实文档再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token``file_type``has_more``page_token``count``count` 是当前页返回的评论卡片数。是否继续分页以 `has_more` 为准,而不是只看 `page_token` 是否存在。
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query``drive file.comment.replys.*`
## 评论卡片模型
- 返回的 `items` 是评论卡片列表,每个 `item` 对应用户界面中的一张评论卡片,不是平铺的互动消息列表。
- 创建评论时会同时创建该卡片里的第一条 reply真正承载正文的是 `item.reply_list.replies`,其中第一条 reply根回复在用户视角下就是这张卡片里的“评论本身”。更新根回复即改写评论正文见 [`lark-drive-update-reply.md`](lark-drive-update-reply.md));删除按 reply 逐条生效,卡片在最后一条回复被删时才消失(见 [`lark-drive-delete-reply.md`](lark-drive-delete-reply.md))。
- `item.has_more=true` 表示该评论卡片下还有回复未包含在本次返回中;这与外层 `has_more`(是否还有下一页评论卡片)是两个不同字段。需要完整回复时继续用 `drive +list-replies --comment-id <id>` 分页拉全。
## 统计口径
- 统计“评论数”或“评论卡片数”:统计 `items` 长度;全量统计时对所有分页返回的 `items` 长度累加。
- 统计“回复数”:统计所有 `item.reply_list.replies` 长度之和,再减去 `items` 长度。
- 统计“总互动数”:统计所有 `item.reply_list.replies` 长度之和,包含每张评论卡片里的首条评论。
- 任一 `item.has_more=true` 时,先用 `drive +list-replies --comment-id <id>` 把该卡片的回复拉全,再做回复数或总互动数统计,否则会少算。
## 排序
- 只有当用户明确提到“最新评论”“最后评论”“最早评论”时,才需要按 `create_time` 排序。
- 排序前必须拉完所有评论分页,不能只取第一页。
- “最新评论”/“最后评论”:按 `create_time` 降序取第一条。“最早评论”:按 `create_time` 升序取第一条。
- 用户只说“第一条评论”时,直接使用返回的第一条,不需要额外排序。
## 输出
@@ -121,5 +78,5 @@ lark-cli drive +list-comments \
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
- [lark-drive-list-replies](lark-drive-list-replies.md) -- 拉全某张卡片下的回复(统计与 `item.has_more` 补全)
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文

View File

@@ -0,0 +1,54 @@
# drive +list-replies
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
分页获取某条评论下的回复。
## 命令
```bash
# 推荐:完整 URL + 评论 ID
lark-cli drive +list-replies --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 评论 ID来自 `drive +list-comments``items[].comment_id` |
| `--page-size` | 否 | 1-100默认 50 |
| `--page-token` | 否 | 上次输出的 `page_token``has_more=true` 时用它续拉 |
| `--need-reaction` | 否 | 在回复上返回 reaction 数据,见 [`lark-drive-reactions.md`](lark-drive-reactions.md) |
## 行为说明
- 根回复承载评论正文本身,是回复列表中创建最早的一条:**仅第一页(未传 `--page-token`)的 `items[0]` 是根回复**;翻页后(传了 `--page-token`)返回的 `items[0]` 只是普通回复,不要按位置当作根回复去更新或删除。
- 输出字段:`items[].reply_id` / `user_id` / `create_time` / `update_time` / `content.elements`,供 `+update-reply``+delete-reply` 使用。
- 检查回复归属(更新/删除前):比对 `items[].user_id`open_id与当前身份判断是不是自己创建的回复。
- 输出的 `items` 始终是 JSON 数组(服务端省略时归一化为 `[]`)。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"items": [],
"has_more": false,
"page_token": "",
"count": 0
}
```
`items` 是回复数组;是否继续翻页以 `has_more` 为准,`has_more=true` 时用返回的 `page_token` 续拉。
## 参考
- [lark-drive-list-comments](lark-drive-list-comments.md) -- 评论卡片模型与统计口径
- [lark-drive-update-reply](lark-drive-update-reply.md) -- 更新回复
- [lark-drive-delete-reply](lark-drive-delete-reply.md) -- 删除回复
- [lark-drive-reactions](lark-drive-reactions.md) -- reaction 查询与写入

View File

@@ -0,0 +1,51 @@
# drive +react-reply
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。reaction 查询规则、语义联想与完整 `reaction_type` 枚举见跨切面专题 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
给一条回复添加或删除表情回应reaction。操作对象始终是 `reply_id`
## 命令
```bash
# 加 reaction
lark-cli drive +react-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --reply-id '<id>' --emoji THUMBSUP --action add
# 删除自己加的 reaction仍需传要删除的那个 --emoji
lark-cli drive +react-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --reply-id '<id>' --emoji THUMBSUP --action delete
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--reply-id` | 是 | 要操作的回复 ID来自 `drive +list-replies``items[].reply_id`。给“这条评论”加/删表情时取该评论根回复(第一页 `items[0]`)的 `reply_id` |
| `--emoji` | 是 | `reaction_type` 值,大小写敏感;本地按平台枚举校验。完整列表与语义映射见 [`lark-drive-reactions.md`](lark-drive-reactions.md) |
| `--action` | 是 | `add` 添加;`delete` 删除当前身份自己加的 reaction |
## 行为说明
- `--emoji` 大小写敏感(如 `THUMBSUP``ThumbsDown`),并做本地枚举校验兜底。服务端不校验 `reaction_type`:任意字符串都会被接受并持久化成一条损坏的 reaction所以本地校验是唯一防线直接调原生命令时必须自行保证取值合法。
- add / delete 幂等:重复添加已有 reaction、删除不存在的 reaction 都会成功返回且无副作用delete 只取消当前身份自己加的 reaction。
- 对根回复操作等价于给评论本身加 / 删表情。
- 读回 reaction`drive +list-replies` / `drive +batch-query-comments` 上带 `--need-reaction``count=0` 的条目是已删除 reaction 的残留,判断存在与否按 `count>0` 过滤。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"reply_id": "<reply_id>",
"reaction_type": "THUMBSUP",
"action": "add",
"updated": true
}
```
## 参考
- [lark-drive-reactions](lark-drive-reactions.md) -- reaction 查询规则、语义与完整枚举
- [lark-drive-list-replies](lark-drive-list-replies.md) -- 获取 reply_id

View File

@@ -1,8 +1,8 @@
# drive reactions
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 了解 Drive 评论入口,再阅读 [`lark-drive-comments-guide.md`](lark-drive-comments-guide.md) 了解评论卡片模型、评论数/回复数统计口径、`file_token` / `file_type` 规则;同时阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 了解 Drive 评论入口,再阅读 [`lark-drive-list-comments.md`](lark-drive-list-comments.md) 了解评论卡片模型、评论数/回复数统计口径、`file_token` / `file_type` 规则;同时阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
处理文档评论 / 回复上的 reaction点赞、表情、各表情数量、谁点了什么、添加/删除表情)。这个场景不常见,但规则比较集中:查询时只有在用户明确需要 reaction 信息时才带 `need_reaction=true`;写入时统一使用 `drive file.comment.reply.reactions update_reaction`,操作对象始终是 `reply_id`
处理文档评论 / 回复上的 reaction点赞、表情、各表情数量、谁点了什么、添加/删除表情)。这个场景不常见,但规则比较集中:查询时只有在用户明确需要 reaction 信息时才`drive +list-comments` / `+batch-query-comments` / `+list-replies``--need-reaction`;写入优先使用 `drive +react-reply`(命令参数细节见 [`lark-drive-react-reply.md`](lark-drive-react-reply.md)),操作对象始终是 `reply_id`。本文是跨切面专题,集中放 reaction 的查询规则、语义联想和完整枚举
> [!IMPORTANT]
> **`reaction_type` 只能使用本文下方“完整 `reaction_type` 列表”中定义的枚举值。**
@@ -16,49 +16,50 @@
## 查询规则
- `drive file.comments list``drive file.comments batch_query``drive file.comment.replys list` 都支持通过指定`need_reaction`查询reaction信息
- `need_reaction` 只在用户明确需要 reaction 信息时再带;如果用户只关心评论正文、回复正文、评论数 / 回复数,默认不要加。
- 遍历评论卡片并顺带拿 reaction使用 `drive file.comments list`
- 已知评论 ID批量查看 reaction使用 `drive file.comments batch_query`,并在请求体里带 `need_reaction=true`
- 某张评论卡片下继续翻页拉 reply reaction使用 `drive file.comment.replys list`
- 如果 `drive file.comments list` 返回的某个 `item.has_more=true`,且用户要完整的 reply reaction 数据,后续每一页 `drive file.comment.replys list` 都要持续带 `need_reaction=true`
- `drive +list-comments``drive +batch-query-comments``drive +list-replies` 都支持 `--need-reaction`
- `--need-reaction` 只在用户明确需要 reaction 信息时再带;如果用户只关心评论正文、回复正文、评论数 / 回复数,默认不要加。
- 遍历评论卡片并顺带拿 reaction使用 `drive +list-comments --need-reaction`
- 已知评论 ID批量查看 reaction使用 `drive +batch-query-comments --need-reaction`
- 某张评论卡片下继续翻页拉 reply reaction使用 `drive +list-replies --need-reaction`,每一页都要持续带
- 返回形状:`items[].reactions[]``{reaction_key, count, ahead_users[]}`**`count=0` 的条目是已删除 reaction 的残留,统计与判断是否存在都要按 `count>0` 过滤**
## 查询示例
```bash
# 遍历评论卡片,并把 reaction 一起拿回来
lark-cli drive file.comments list \
--params '{"file_token":"<DOC_TOKEN>","file_type":"docx","need_reaction":true}'
lark-cli drive +list-comments --url '<DOC_URL>' --need-reaction
# 已知 comment_id批量查询评论卡片 reaction
lark-cli drive file.comments batch_query \
--params '{"file_token":"<DOC_TOKEN>","file_type":"docx"}' \
--data '{"comment_ids":["<COMMENT_ID>"],"need_reaction":true}'
lark-cli drive +batch-query-comments --url '<DOC_URL>' --comment-ids '<COMMENT_ID>' --need-reaction
# 继续翻某张评论卡片下的 replies并把 reaction 一起拿回来
lark-cli drive file.comment.replys list \
--params '{"file_token":"<DOC_TOKEN>","comment_id":"<COMMENT_ID>","file_type":"docx","need_reaction":true}'
lark-cli drive +list-replies --url '<DOC_URL>' --comment-id '<COMMENT_ID>' --need-reaction
```
## 写入规则
- 添加 / 删除 reaction 时,使用 `drive file.comment.reply.reactions update_reaction`
- 请求里必须带正确的 `file_type`,并在 body 中传 `action=add|delete``reply_id``reaction_type`
- `update_reaction` 的操作对象是 `reply_id`,不是 `comment_id`
- 如果用户说要给“这条评论”加 / 删 reaction通常需要定位到该评论卡片首条 reply 的 `reply_id` 再操作
- 添加 / 删除 reaction 优先使用 `drive +react-reply`;命令参数、目标定位和 dry-run 见 [`lark-drive-react-reply.md`](lark-drive-react-reply.md)
- 操作对象是 `reply_id`(来自 `drive +list-replies``items[].reply_id`),不是 `comment_id`
- 如果用户说要给"这条评论"加 / 删 reaction取该评论卡片根回复第一页 `items[0]`)的 `reply_id` 再操作
- add / delete 幂等:重复添加已有 reaction、删除不存在的 reaction 都会成功返回且无副作用delete 只取消当前身份自己加的 reaction
- **服务端不校验 `reaction_type`:任意字符串都会被接受并持久化成一条损坏的 reaction**`+react-reply --emoji` 会按平台枚举做本地校验兜底,直接调原生命令时必须自行保证取值合法。
- 原生 `drive file.comment.reply.reactions update_reaction` 只在需要 shortcut 未暴露的字段时兜底使用,`--params``file_token`/`file_type``--data``action=add|delete``reply_id``reaction_type`
## 写入示例
```bash
# 给某条 reply 添加一个点赞 reaction
lark-cli drive +react-reply --url '<DOC_URL>' \
--reply-id '<REPLY_ID>' --emoji THUMBSUP --action add
# 删除某条 reply 上已有的 DONE reactionwiki URL 自动解包)
lark-cli drive +react-reply --url '<WIKI_URL>' \
--reply-id '<REPLY_ID>' --emoji DONE --action delete
# 原生命令兜底(注意:原生路径没有本地枚举校验)
lark-cli drive file.comment.reply.reactions update_reaction \
--params '{"file_token":"<DOC_TOKEN>","file_type":"docx"}' \
--data '{"action":"add","reply_id":"<REPLY_ID>","reaction_type":"THUMBSUP"}'
# 删除某条 reply 上已有的 DONE reaction
lark-cli drive file.comment.reply.reactions update_reaction \
--params '{"file_token":"<DOC_TOKEN>","file_type":"docx"}' \
--data '{"action":"delete","reply_id":"<REPLY_ID>","reaction_type":"DONE"}'
```
> [!CAUTION]
@@ -66,7 +67,7 @@ lark-cli drive file.comment.reply.reactions update_reaction \
## `reaction_type` 使用规则
- `reaction_type` 必须传平台定义的枚举字符串,大小写敏感。
- `reaction_type` 必须传平台定义的枚举字符串,大小写敏感`drive +react-reply``--emoji` 会本地校验(原生命令不校验、服务端也不校验)
- 不要擅自把 mixed-case 值改成全大写,例如 `Yes``No``Get``EatingFood``CheckMark``CrossMark` 都要按原值传。
- **不要编造列表外的 `reaction_type`,也不要把自然语言描述臆造成平台未定义的新枚举**。
- 如果用户给的是自然语言语义(如“点赞”“在处理中”“确认一下”),可以在下方枚举列表内选择语义最接近的现有值;如果是近似映射,应在执行时明确告知用户。
@@ -110,4 +111,5 @@ Music, Typing, Pepper, CheckMark, CrossMark
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-react-reply](lark-drive-react-reply.md) -- `+react-reply` 命令参数
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,45 @@
# drive +resolve-comment
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
把一条评论标记为已解决。反向操作——重新打开已解决评论——是独立命令 [`lark-drive-restore-comment.md`](lark-drive-restore-comment.md)。
用户说“把这条评论标记为已处理 / 已完成 / 关闭”对应本命令。
## 命令
```bash
# 推荐:完整 URL + 评论 ID
lark-cli drive +resolve-comment --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 要解决的评论 ID来自 `drive +list-comments``items[].comment_id` |
## 行为说明
- 这是写操作。
- 对同一条评论连续翻转解决状态可能触发服务端限流HTTP 429连续调用之间留间隔或短暂延迟后重试。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"action": "resolve",
"is_solved": true,
"updated": true
}
```
## 参考
- [lark-drive-restore-comment](lark-drive-restore-comment.md) -- 恢复(重新打开)评论

View File

@@ -0,0 +1,46 @@
# drive +restore-comment
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
恢复 / 重新打开一条已解决的评论。反向操作——把评论标记为已解决——是独立命令 [`lark-drive-resolve-comment.md`](lark-drive-resolve-comment.md)。
用户说“重新打开 / 取消解决 / 恢复这条评论”对应本命令。
## 命令
```bash
# 推荐:完整 URL + 评论 ID
lark-cli drive +restore-comment --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 要恢复的评论 ID来自 `drive +list-comments``items[].comment_id` |
## 行为说明
- 这是写操作。
- **找目标评论必须带 `--solved-status`**`drive +list-comments` 默认只返回未解决评论,本命令的目标恰好是已解决评论,直接用默认口径查会一条都找不到。先用 `drive +list-comments --solved-status true`(只看已解决)或 `--solved-status all`(全部)取 `items[].comment_id`
- 对同一条评论连续翻转解决状态可能触发服务端限流HTTP 429连续调用之间留间隔或短暂延迟后重试。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"action": "restore",
"is_solved": false,
"updated": true
}
```
## 参考
- [lark-drive-resolve-comment](lark-drive-resolve-comment.md) -- 解决(标记已解决)评论

View File

@@ -0,0 +1,46 @@
# drive +update-reply
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理;`--content` 完整格式见 [`lark-drive-comment-content.md`](lark-drive-comment-content.md)。
整体替换某条回复的内容。
## 命令
```bash
# 推荐:完整 URL + 评论 ID + 回复 ID + 新内容(整体替换,无局部编辑)
lark-cli drive +update-reply --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --comment-id '<id>' --reply-id '<id>' --content '[{"type":"text","text":"新内容"}]'
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URLapps 妙搭 URL 使用 `/page/<token>`wiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``apps``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--comment-id` | 是 | 回复所属的评论 ID来自 `drive +list-comments` |
| `--reply-id` | 是 | 要更新的回复 ID来自 `drive +list-replies``items[].reply_id` |
| `--content` | 是 | 新的 `reply_elements` JSON`type=text` 文本自动转义;完整 schema 见 [`lark-drive-comment-content.md`](lark-drive-comment-content.md) |
## 行为说明
- 更新是整体替换:新 `content` 完全覆盖旧内容,没有局部修改语义。
- **只能更新当前身份自己创建的回复**;更新他人回复返回 API 错误 `1069303 forbidden`。执行前先用 `+list-replies` 核对 `items[].user_id`open_id并用创建该回复的同一个 `--as` 身份执行。
- 更新评论卡片的根回复(第一页 `items[0]`,即创建最早的一条 reply等价于改写这条评论的正文本身改写前先和用户确认改的是回复还是评论正文。
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"comment_id": "<comment_id>",
"reply_id": "<reply_id>",
"updated": true
}
```
## 参考
- [lark-drive-comment-content](lark-drive-comment-content.md) -- `--content` 格式
- [lark-drive-list-replies](lark-drive-list-replies.md) -- 获取回复与 reply_id

View File

@@ -105,7 +105,7 @@ lark-cli slides +replace-slide --as user \
`+media-upload` 内部调用 `POST /open-apis/drive/v1/medias/upload_all`(单次上传,最大 20 MB固定使用
- `parent_type=slide_file`slides 后端唯一接受的取值,已实测验证
- `parent_type=slide_file`slides 后端唯一接受的取值)
- `parent_node=<xml_presentation_id>`
**不要尝试用 `slides_image`、`slide_image` 等 parent_type**——后端会返回 1061001 / 1061002 错误。这是 slides 的特殊约定。

View File

@@ -0,0 +1,908 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Internal XSD model and constraint validation for the Slides lint entrypoint."""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from pathlib import Path
from typing import Any
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
SML_READBACK_NAMESPACE = "/sml/2.0"
SML_HTTPS_READBACK_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
ACCEPTED_SML_NAMESPACES = frozenset(
(SML_NAMESPACE, SML_READBACK_NAMESPACE, SML_HTTPS_READBACK_NAMESPACE)
)
def local_name(value: str) -> str:
if value.startswith("{"):
return value.rsplit("}", 1)[-1]
return value.rsplit(":", 1)[-1]
def direct_children(element: ET.Element, name: str) -> list[ET.Element]:
return [child for child in element if child.tag == f"{XS_NS}{name}"]
def first_direct_child(element: ET.Element, *names: str) -> ET.Element | None:
wanted = {f"{XS_NS}{name}" for name in names}
return next((child for child in element if child.tag in wanted), None)
def occurs_value(raw: str | None, default: int) -> int | None:
if raw == "unbounded":
return None
return int(raw) if raw is not None else default
@dataclass(frozen=True)
class SimpleTypeRule:
name: str
base: str | None = None
enums: tuple[str, ...] = ()
patterns: tuple[str, ...] = ()
bounds: tuple[tuple[str, Decimal], ...] = ()
length_bounds: tuple[tuple[str, int], ...] = ()
union_members: tuple[str, ...] = ()
@dataclass(frozen=True)
class AttributeRule:
name: str
type_name: str
required: bool
@dataclass(frozen=True)
class ElementRule:
name: str
type_name: str | None
inline_complex_type: ET.Element | None
ref_name: str | None
@dataclass(frozen=True)
class ChildRule:
element: ElementRule
min_occurs: int
max_occurs: int | None
order: int | None
@dataclass(frozen=True)
class ChoiceRequirement:
names: tuple[str, ...]
min_occurs: int
max_occurs: int | None
@dataclass(frozen=True)
class SchemaModel:
simple_types: dict[str, SimpleTypeRule]
complex_types: dict[str, ET.Element]
element_candidates: dict[str, tuple[ElementRule, ...]]
global_elements: dict[str, ElementRule]
def parse_simple_type(
element: ET.Element,
fallback_name: str,
simple_types: dict[str, SimpleTypeRule] | None = None,
) -> SimpleTypeRule:
name = element.attrib.get("name", fallback_name)
restriction = first_direct_child(element, "restriction")
union = first_direct_child(element, "union")
if union is not None:
union_members = [
local_name(member) for member in union.attrib.get("memberTypes", "").split()
]
for index, inline_simple in enumerate(direct_children(union, "simpleType"), start=1):
inline_name = f"__inline_union_member_{name}_{index}"
union_members.append(inline_name)
if simple_types is not None:
simple_types[inline_name] = parse_simple_type(
inline_simple,
inline_name,
simple_types,
)
return SimpleTypeRule(
name=name,
union_members=tuple(union_members),
)
if restriction is None:
return SimpleTypeRule(name=name)
facet_names = {
"minInclusive",
"minExclusive",
"maxInclusive",
"maxExclusive",
}
bounds: list[tuple[str, Decimal]] = []
length_bounds: list[tuple[str, int]] = []
for child in restriction:
facet = local_name(child.tag)
if "value" not in child.attrib:
continue
if facet in facet_names:
bounds.append((facet, Decimal(child.attrib["value"])))
elif facet in {"minLength", "maxLength"}:
length_bounds.append((facet, int(child.attrib["value"])))
return SimpleTypeRule(
name=name,
base=local_name(restriction.attrib.get("base", "string")),
enums=tuple(child.attrib["value"] for child in direct_children(restriction, "enumeration")),
patterns=tuple(child.attrib["value"] for child in direct_children(restriction, "pattern")),
bounds=tuple(bounds),
length_bounds=tuple(length_bounds),
)
def parse_element_rule(element: ET.Element) -> ElementRule | None:
raw_ref = element.attrib.get("ref")
name = element.attrib.get("name")
if name is None and raw_ref:
name = local_name(raw_ref)
if not name:
return None
return ElementRule(
name=name,
type_name=local_name(element.attrib["type"]) if element.attrib.get("type") else None,
inline_complex_type=first_direct_child(element, "complexType"),
ref_name=local_name(raw_ref) if raw_ref else None,
)
@lru_cache(maxsize=4)
def load_schema_model(schema_path: str) -> SchemaModel:
root = ET.parse(schema_path).getroot()
simple_types: dict[str, SimpleTypeRule] = {}
for element in direct_children(root, "simpleType"):
name = element.attrib.get("name")
if not name:
continue
simple_types[name] = parse_simple_type(element, name, simple_types)
for attribute in root.iter(f"{XS_NS}attribute"):
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is None:
continue
inline_name = f"__inline_attribute_{attribute.attrib.get('name', 'anonymous')}_{id(attribute)}"
simple_types[inline_name] = parse_simple_type(inline_simple, inline_name, simple_types)
complex_types = {
element.attrib["name"]: element
for element in direct_children(root, "complexType")
if element.attrib.get("name")
}
candidates: dict[str, list[ElementRule]] = {}
for element in root.iter(f"{XS_NS}element"):
rule = parse_element_rule(element)
if rule is not None:
candidates.setdefault(rule.name, []).append(rule)
global_elements = {
rule.name: rule
for element in direct_children(root, "element")
if (rule := parse_element_rule(element)) is not None
}
return SchemaModel(
simple_types=simple_types,
complex_types=complex_types,
element_candidates={name: tuple(rules) for name, rules in candidates.items()},
global_elements=global_elements,
)
def attributes_for_complex_type(
complex_type: ET.Element,
model: SchemaModel,
resolving: set[str] | None = None,
) -> dict[str, AttributeRule]:
resolving = resolving or set()
attributes: dict[str, AttributeRule] = {}
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is None:
continue
base_name = local_name(extension.attrib.get("base", ""))
if base_name in model.complex_types and base_name not in resolving:
resolving.add(base_name)
attributes.update(attributes_for_complex_type(model.complex_types[base_name], model, resolving))
resolving.remove(base_name)
attributes.update(direct_attribute_rules(extension))
attributes.update(direct_attribute_rules(complex_type))
return attributes
def direct_attribute_rules(element: ET.Element) -> dict[str, AttributeRule]:
rules: dict[str, AttributeRule] = {}
for attribute in direct_children(element, "attribute"):
name = attribute.attrib.get("name")
if not name:
continue
type_name = local_name(attribute.attrib.get("type", "string"))
inline_simple = first_direct_child(attribute, "simpleType")
if inline_simple is not None:
type_name = f"__inline_attribute_{name}_{id(attribute)}"
rules[name] = AttributeRule(
name=name,
type_name=type_name,
required=attribute.attrib.get("use") == "required",
)
return rules
def attributes_for_element(rule: ElementRule, model: SchemaModel) -> dict[str, AttributeRule]:
complex_type = rule.inline_complex_type
if complex_type is None and rule.type_name in model.complex_types:
complex_type = model.complex_types[rule.type_name]
if complex_type is None:
return {}
return attributes_for_complex_type(complex_type, model)
def best_element_rule(element_name: str, model: SchemaModel) -> ElementRule | None:
candidates = model.element_candidates.get(element_name, ())
if not candidates:
return None
return max(
candidates,
key=lambda candidate: (
candidate.type_name is not None or candidate.inline_complex_type is not None,
len(attributes_for_element(candidate, model)),
),
)
def concrete_element_rule(rule: ElementRule, model: SchemaModel) -> ElementRule:
if rule.ref_name is not None:
return model.global_elements.get(rule.ref_name, rule)
if rule.type_name is not None or rule.inline_complex_type is not None:
return rule
candidate = best_element_rule(rule.name, model)
return candidate or rule
def complex_type_for_element(rule: ElementRule, model: SchemaModel) -> ET.Element | None:
rule = concrete_element_rule(rule, model)
if rule.inline_complex_type is not None:
return rule.inline_complex_type
if rule.type_name is not None:
return model.complex_types.get(rule.type_name)
return None
def particle_for_complex_type(complex_type: ET.Element) -> ET.Element | None:
particle = first_direct_child(complex_type, "sequence", "all", "choice")
if particle is not None:
return particle
for content_name in ("simpleContent", "complexContent"):
content = first_direct_child(complex_type, content_name)
if content is None:
continue
extension = first_direct_child(content, "extension")
if extension is not None:
return first_direct_child(extension, "sequence", "all", "choice")
return None
def multiplied_max(left: int | None, right: int | None) -> int | None:
if left is None or right is None:
return None
return left * right
def child_rules_for_complex_type(
complex_type: ET.Element,
) -> tuple[list[ChildRule], list[ChoiceRequirement]]:
particle = particle_for_complex_type(complex_type)
if particle is None:
return [], []
rules: list[ChildRule] = []
requirements: list[ChoiceRequirement] = []
next_order = 0
def add_element(
element: ET.Element,
*,
order: int | None,
optional_by_choice: bool,
max_multiplier: int | None,
) -> None:
element_rule = parse_element_rule(element)
if element_rule is None:
return
minimum = occurs_value(element.attrib.get("minOccurs"), 1) or 0
maximum = occurs_value(element.attrib.get("maxOccurs"), 1)
rules.append(
ChildRule(
element=element_rule,
min_occurs=0 if optional_by_choice else minimum,
max_occurs=multiplied_max(maximum, max_multiplier),
order=order,
)
)
def walk_group(
group: ET.Element,
*,
ordered: bool,
fixed_order: int | None = None,
optional_by_choice: bool = False,
max_multiplier: int | None = 1,
) -> None:
nonlocal next_order
kind = local_name(group.tag)
group_min = occurs_value(group.attrib.get("minOccurs"), 1) or 0
group_max = occurs_value(group.attrib.get("maxOccurs"), 1)
effective_max = multiplied_max(max_multiplier, group_max)
if kind == "choice":
choice_order = fixed_order
if choice_order is None and ordered:
choice_order = next_order
next_order += 1
names: list[str] = []
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
parsed = parse_element_rule(child)
if parsed is not None:
names.append(parsed.name)
add_element(
child,
order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered,
fixed_order=choice_order,
optional_by_choice=True,
max_multiplier=effective_max,
)
if names and (group_min > 0 or effective_max is not None):
requirements.append(ChoiceRequirement(tuple(names), group_min, effective_max))
return
group_ordered = kind == "sequence"
for child in group:
child_kind = local_name(child.tag)
if child_kind == "element":
child_order = fixed_order
if child_order is None and ordered and group_ordered:
child_order = next_order
next_order += 1
add_element(
child,
order=child_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
elif child_kind in {"sequence", "all", "choice"}:
walk_group(
child,
ordered=ordered and group_ordered,
fixed_order=fixed_order,
optional_by_choice=optional_by_choice or group_min == 0,
max_multiplier=effective_max,
)
walk_group(particle, ordered=local_name(particle.tag) == "sequence")
return rules, requirements
def issue(
code: str,
path: str,
tag: str,
*,
attr: str | None,
expected: str,
actual: Any,
message: str,
hint: str,
) -> dict[str, Any]:
result: dict[str, Any] = {
"level": "error",
"code": code,
"path": path,
"tag": tag,
"expected": expected,
"actual": actual,
"message": message,
"hint": hint,
}
if attr is not None:
result["attr"] = attr
return result
def builtin_scalar_value(type_name: str, value: str) -> Decimal | str | bool:
if type_name in {"string", "anyURI"}:
return value
if type_name == "boolean":
if value not in {"true", "false", "1", "0"}:
raise ValueError("expected boolean")
return value in {"true", "1"}
if type_name in {"integer", "positiveInteger", "nonNegativeInteger"}:
if re.fullmatch(r"[+-]?\d+", value) is None:
raise ValueError("expected integer")
number = Decimal(value)
if type_name == "positiveInteger" and number <= 0:
raise ArithmeticError("expected positive integer")
if type_name == "nonNegativeInteger" and number < 0:
raise ArithmeticError("expected non-negative integer")
return number
if type_name in {"double", "decimal"}:
lexical_value = value.strip(" \t\n\r")
decimal_pattern = r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)"
double_pattern = decimal_pattern + r"(?:[eE][+-]?[0-9]+)?"
expected_pattern = double_pattern if type_name == "double" else decimal_pattern
if re.fullmatch(expected_pattern, lexical_value) is None:
raise ValueError(f"expected {type_name}")
try:
number = Decimal(lexical_value)
except InvalidOperation as error:
raise ValueError(f"expected {type_name}") from error
if not math.isfinite(float(number)):
raise ValueError(f"expected finite {type_name}")
return number
return value
def scalar_value_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> Decimal | str | bool:
resolving = resolving or set()
if type_name in resolving:
return value
rule = model.simple_types.get(type_name)
if rule is None or rule.base is None:
return builtin_scalar_value(type_name, value)
resolving.add(type_name)
try:
return scalar_value_for_type(rule.base, value, model, resolving)
finally:
resolving.remove(type_name)
@lru_cache(maxsize=32)
def python_pattern_for_xsd(pattern: str) -> str:
translated: list[str] = []
in_character_class = False
index = 0
while index < len(pattern):
char = pattern[index]
if char == "\\" and index + 1 < len(pattern):
escaped = pattern[index + 1]
if escaped in {"s", "S"}:
body = r" \t\n\r"
if in_character_class:
if escaped == "S":
raise ValueError(
f"unsupported complemented XSD character class \\{escaped} inside []"
)
translated.append(body)
else:
prefix = "^" if escaped == "S" else ""
translated.append(f"[{prefix}{body}]")
index += 2
continue
translated.extend((char, escaped))
index += 2
continue
if char == "[":
in_character_class = True
elif char == "]":
in_character_class = False
elif char == "." and not in_character_class:
translated.append(r"[^\n\r]")
index += 1
continue
elif char in "^$" and not in_character_class:
translated.append(f"\\{char}")
index += 1
continue
translated.append(char)
index += 1
return "".join(translated)
def xsd_pattern_matches(pattern: str, value: str) -> bool:
if pattern == r"[\w.-]+[.:]\S*":
if any(character in " \t\n\r" for character in value):
return False
for index, character in enumerate(value):
if index > 0 and character in ".:":
return True
if not (character == "_" or character.isalnum() or character in ".-"):
return False
return False
return re.fullmatch(python_pattern_for_xsd(pattern), value) is not None
def value_error_for_type(
type_name: str,
value: str,
model: SchemaModel,
resolving: set[str] | None = None,
) -> tuple[str, str] | None:
resolving = resolving or set()
if type_name in resolving:
return None
rule = model.simple_types.get(type_name)
if rule is None:
try:
builtin_scalar_value(type_name, value)
except ValueError:
return "sxsd_invalid_scalar", f"value valid for {type_name}"
except ArithmeticError:
return "sxsd_value_out_of_range", f"value in the range allowed by {type_name}"
return None
resolving.add(type_name)
try:
if rule.union_members:
member_errors = [value_error_for_type(member, value, model, resolving) for member in rule.union_members]
if any(error is None for error in member_errors):
return None
unsupported_error = next(
(error for error in member_errors if error and error[0] == "sxsd_unsupported_pattern"),
None,
)
if unsupported_error is not None:
return unsupported_error
if any(error and error[0] == "sxsd_pattern_mismatch" for error in member_errors):
return "sxsd_pattern_mismatch", f"value matching one member of {type_name}"
return member_errors[0]
if rule.enums and value not in rule.enums:
return "sxsd_invalid_enum", "one of: " + ", ".join(rule.enums)
if rule.patterns:
unsupported_patterns: list[str] = []
for pattern in rule.patterns:
try:
if xsd_pattern_matches(pattern, value):
break
except (ValueError, re.error) as error:
unsupported_patterns.append(f"{pattern!r}: {error}")
else:
if unsupported_patterns:
return (
"sxsd_unsupported_pattern",
"lint support for XSD pattern " + "; ".join(unsupported_patterns),
)
return "sxsd_pattern_mismatch", "value matching pattern " + " or ".join(rule.patterns)
base_name = rule.base or "string"
base_error = value_error_for_type(base_name, value, model, resolving)
if base_error is not None:
return base_error
for facet, bound in rule.length_bounds:
allowed = len(value) >= bound if facet == "minLength" else len(value) <= bound
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
scalar = scalar_value_for_type(base_name, value, model)
if isinstance(scalar, Decimal):
for facet, bound in rule.bounds:
allowed = {
"minInclusive": scalar >= bound,
"minExclusive": scalar > bound,
"maxInclusive": scalar <= bound,
"maxExclusive": scalar < bound,
}[facet]
if not allowed:
return "sxsd_value_out_of_range", f"{facet} {bound}"
return None
finally:
resolving.remove(type_name)
def validate_element_attributes(
element: ET.Element,
path: str,
model: SchemaModel,
element_rule: ElementRule | None = None,
) -> list[dict[str, Any]]:
tag = local_name(element.tag)
element_rule = element_rule or best_element_rule(tag, model)
if element_rule is None:
return []
attribute_rules = attributes_for_element(element_rule, model)
issues: list[dict[str, Any]] = []
for attr_rule in attribute_rules.values():
if attr_rule.required and attr_rule.name not in element.attrib:
issues.append(
issue(
"sxsd_missing_required_attr",
path,
tag,
attr=attr_rule.name,
expected=f"required attribute of type {attr_rule.type_name}",
actual=None,
message=f'missing required SXSD attribute "{attr_rule.name}" on <{tag}> at {path}',
hint=f'Add attribute "{attr_rule.name}" with a value valid for {attr_rule.type_name}.',
)
)
for raw_name, value in element.attrib.items():
attr_name = local_name(raw_name)
attr_rule = attribute_rules.get(attr_name)
if attr_rule is None:
continue
validation_error = value_error_for_type(attr_rule.type_name, value, model)
if validation_error is None:
continue
code, expected = validation_error
if code == "sxsd_unsupported_pattern":
message = (
f'unsupported SXSD pattern for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = (
f"Extend the SXSD pattern interpreter for {attr_rule.type_name}; "
"do not treat this attribute value as validated."
)
else:
message = (
f'invalid SXSD value {value!r} for attribute "{attr_name}" on <{tag}> at {path}'
)
hint = f'Set attribute "{attr_name}" to a value valid for {attr_rule.type_name}.'
issues.append(
issue(
code,
path,
tag,
attr=attr_name,
expected=expected,
actual=value,
message=message,
hint=hint,
)
)
return issues
def element_namespace(tag: str) -> str | None:
if not tag.startswith("{"):
return None
return tag[1:].split("}", 1)[0]
def validate_element_children(
element: ET.Element,
path: str,
element_rule: ElementRule,
model: SchemaModel,
) -> tuple[list[dict[str, Any]], dict[int, ElementRule]]:
tag = local_name(element.tag)
complex_type = complex_type_for_element(element_rule, model)
child_rules, choice_requirements = (
child_rules_for_complex_type(complex_type) if complex_type is not None else ([], [])
)
rules_by_name: dict[str, list[ChildRule]] = {}
for child_rule in child_rules:
rules_by_name.setdefault(child_rule.element.name, []).append(child_rule)
issues: list[dict[str, Any]] = []
matched: dict[int, ElementRule] = {}
counts: dict[str, int] = {}
latest_order = -1
for child in element:
child_name = local_name(child.tag)
child_path = f"{path}/{child_name}"
candidates = rules_by_name.get(child_name, [])
if not candidates:
issues.append(
issue(
"sxsd_unexpected_child",
child_path,
child_name,
attr=None,
expected="one of: " + ", ".join(sorted(rules_by_name)) if rules_by_name else "no child elements",
actual=child_name,
message=f"unexpected SXSD child <{child_name}> under <{tag}> at {child_path}",
hint=f"Move or remove <{child_name}> so <{tag}> follows the SXSD child structure.",
)
)
continue
child_rule = candidates[0]
if child_rule.order is not None:
if child_rule.order < latest_order:
issues.append(
issue(
"sxsd_invalid_child_order",
child_path,
child_name,
attr=None,
expected="children in xs:sequence order",
actual=child_name,
message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
)
)
latest_order = max(latest_order, child_rule.order)
counts[child_name] = counts.get(child_name, 0) + 1
if child_rule.max_occurs is not None and counts[child_name] > child_rule.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
child_path,
child_name,
attr=None,
expected=f"at most {child_rule.max_occurs}",
actual=counts[child_name],
message=f"too many SXSD <{child_name}> children under <{tag}> at {path}",
hint=f"Keep at most {child_rule.max_occurs} <{child_name}> children under <{tag}>.",
)
)
matched[id(child)] = concrete_element_rule(child_rule.element, model)
for child_rule in child_rules:
child_name = child_rule.element.name
actual_count = counts.get(child_name, 0)
if child_rule.min_occurs <= actual_count:
continue
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"{child_name} (at least {child_rule.min_occurs})",
actual=actual_count,
message=f"missing required SXSD child <{child_name}> under <{tag}> at {path}",
hint=f"Add at least {child_rule.min_occurs} <{child_name}> child under <{tag}>.",
)
)
for requirement in choice_requirements:
actual_count = sum(counts.get(name, 0) for name in requirement.names)
expected_names = ", ".join(requirement.names)
if actual_count < requirement.min_occurs:
issues.append(
issue(
"sxsd_missing_required_child",
path,
tag,
attr=None,
expected=f"one of: {expected_names} (at least {requirement.min_occurs})",
actual=actual_count,
message=f"missing required SXSD choice child under <{tag}> at {path}",
hint=f"Add at least {requirement.min_occurs} child from: {expected_names}.",
)
)
if requirement.max_occurs is not None and actual_count > requirement.max_occurs:
issues.append(
issue(
"sxsd_too_many_children",
path,
tag,
attr=None,
expected=f"at most {requirement.max_occurs} child from: {expected_names}",
actual=actual_count,
message=f"too many SXSD choice children under <{tag}> at {path}",
hint=f"Keep at most {requirement.max_occurs} child from: {expected_names}.",
)
)
return issues, matched
def validate_sxsd(root: ET.Element, schema_path: Path) -> list[dict[str, Any]]:
model = load_schema_model(str(schema_path.resolve()))
issues: list[dict[str, Any]] = []
root_name = local_name(root.tag)
document_namespace = element_namespace(root.tag)
is_bare_slide_fragment = root_name == "slide" and document_namespace is None
has_valid_document_namespace = (
document_namespace in ACCEPTED_SML_NAMESPACES or is_bare_slide_fragment
)
def visit(element: ET.Element, parent_path: str, element_rule: ElementRule) -> None:
tag = local_name(element.tag)
path = f"{parent_path}/{tag}" if parent_path else tag
namespace = element_namespace(element.tag)
invalid_root_namespace = (
not parent_path
and namespace not in ACCEPTED_SML_NAMESPACES
and not is_bare_slide_fragment
)
invalid_descendant_namespace = (
bool(parent_path)
and has_valid_document_namespace
and namespace != document_namespace
)
if invalid_root_namespace or invalid_descendant_namespace:
expected_namespace = document_namespace if parent_path else SML_NAMESPACE
namespace_hint = (
"Keep SXSD descendants without xmlns in a bare <slide> readback fragment."
if expected_namespace is None
else f'Use xmlns="{expected_namespace}" for SXSD elements.'
)
issues.append(
issue(
"sxsd_invalid_namespace",
path,
tag,
attr=None,
expected=expected_namespace,
actual=namespace,
message=f"invalid SXSD namespace on <{tag}> at {path}",
hint=namespace_hint,
)
)
issues.extend(validate_element_attributes(element, path, model, element_rule))
child_issues, matched = validate_element_children(element, path, element_rule, model)
issues.extend(child_issues)
for child in element:
child_rule = matched.get(id(child))
if child_rule is not None:
visit(child, path, child_rule)
if root_name not in {"presentation", "slide"}:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
if root_name == "presentation":
root_rule = model.global_elements.get("presentation")
elif "SlideType" in model.complex_types:
root_rule = ElementRule("slide", "SlideType", None, None)
else:
root_rule = None
if root_rule is None:
issues.append(
issue(
"sxsd_unexpected_root",
root_name,
root_name,
attr=None,
expected="presentation or slide",
actual=root_name,
message=f"unsupported SXSD root <{root_name}>",
hint="Use a <presentation> or <slide> root.",
)
)
return issues
visit(root, "", root_rule)
return issues
def load_tag_attributes(schema_path: Path) -> dict[str, set[str]]:
model = load_schema_model(str(schema_path.resolve()))
tag_attributes: dict[str, set[str]] = {}
for tag_name, candidates in model.element_candidates.items():
attrs = tag_attributes.setdefault(tag_name, set())
for candidate in candidates:
attrs.update(attributes_for_element(concrete_element_rule(candidate, model), model))
return tag_attributes

View File

@@ -5,6 +5,7 @@
from __future__ import annotations
import copy
import json
import math
import re
@@ -16,6 +17,8 @@ from difflib import SequenceMatcher, get_close_matches
from pathlib import Path
from typing import Any
import sxsd_validator
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
XML_NS = "{http://www.w3.org/XML/1998/namespace}"
@@ -44,9 +47,8 @@ ROUNDTRIP_SXSD_ATTRS = {
("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
# it is server-emitted and absent from the write schema, so it must not block page linting.
ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
@@ -310,77 +312,13 @@ def xml_namespace(tag: str) -> str | None:
return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None
def strip_xsd_prefix(value: str | None) -> str | None:
if value is None:
return None
return value.rsplit(":", 1)[-1]
def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]:
return [child for child in element if child.tag == f"{XS_NS}{local_name}"]
def load_sxsd_tag_attributes() -> dict[str, set[str]]:
global _SXSD_TAG_ATTRIBUTES_CACHE
if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
return _SXSD_TAG_ATTRIBUTES_CACHE
schema_root = ET.parse(SXSD_SCHEMA_PATH).getroot()
named_complex_types = {
complex_type.attrib["name"]: complex_type
for complex_type in schema_root.findall(f"{XS_NS}complexType")
if complex_type.attrib.get("name")
}
resolving: set[str] = set()
def attributes_for_complex_type(complex_type: ET.Element) -> set[str]:
attrs: set[str] = {
attribute.attrib["name"]
for attribute in iter_direct_xsd_children(complex_type, "attribute")
if attribute.attrib.get("name")
}
for content_name in ("simpleContent", "complexContent"):
for complex_content in iter_direct_xsd_children(complex_type, content_name):
for extension in iter_direct_xsd_children(complex_content, "extension"):
base_type = strip_xsd_prefix(extension.attrib.get("base"))
if base_type:
attrs.update(attributes_for_type(base_type))
attrs.update(
attribute.attrib["name"]
for attribute in iter_direct_xsd_children(extension, "attribute")
if attribute.attrib.get("name")
)
return attrs
def attributes_for_type(type_name: str) -> set[str]:
if type_name in resolving:
return set()
complex_type = named_complex_types.get(type_name)
if complex_type is None:
return set()
resolving.add(type_name)
try:
return attributes_for_complex_type(complex_type)
finally:
resolving.remove(type_name)
tag_attributes: dict[str, set[str]] = {}
for element in schema_root.iter(f"{XS_NS}element"):
tag_name = element.attrib.get("name")
if not tag_name:
continue
attrs: set[str] = set()
type_name = strip_xsd_prefix(element.attrib.get("type"))
if type_name:
attrs.update(attributes_for_type(type_name))
for complex_type in iter_direct_xsd_children(element, "complexType"):
attrs.update(attributes_for_complex_type(complex_type))
tag_attributes.setdefault(tag_name, set()).update(attrs)
_SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes
return tag_attributes
_SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH)
return _SXSD_TAG_ATTRIBUTES_CACHE
def load_iconpark_icon_types() -> set[str]:
@@ -417,13 +355,19 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."
def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]:
alias = SXSD_ATTR_ALIASES.get(attr_name)
if alias and alias in allowed_attrs:
return f'Use "{alias}" on <{tag_name}> instead of "{attr_name}".'
close_matches = get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
if close_matches:
return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in close_matches) + "?"
return [alias]
return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
if suggestions:
if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]:
return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".'
return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?"
allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
if len(allowed_attrs) > 8:
allowed_summary += ", ..."
@@ -438,10 +382,33 @@ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool:
return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS
def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element:
sanitized_root = copy.deepcopy(root)
def sanitize(element: ET.Element) -> None:
tag_name = xml_local_name(element.tag)
for raw_attr_name in list(element.attrib):
if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)):
del element.attrib[raw_attr_name]
for child in list(element):
if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)):
element.remove(child)
continue
sanitize(child)
sanitize(sanitized_root)
return sanitized_root
def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]:
tag_attributes = load_sxsd_tag_attributes()
supported_tags = set(tag_attributes)
issues: list[dict[str, Any]] = []
suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {}
def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
if should_skip_sxsd_subtree(element, ancestors):
@@ -449,7 +416,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
tag_name = xml_local_name(element.tag)
current_path = f"{path}/{tag_name}" if path else tag_name
if tag_name in ROUNDTRIP_SXSD_TAGS:
parent_name = ancestors[-1] if ancestors else None
if should_skip_sxsd_tag(parent_name, tag_name):
return
if tag_name not in supported_tags:
issues.append(
@@ -473,6 +441,11 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
continue
if attr_name in allowed_attrs:
continue
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
if suggestions:
suggested_attr_candidates.setdefault((current_path, tag_name), []).append(
set(suggestions)
)
issues.append(
{
"level": "error",
@@ -489,6 +462,76 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
visit(child, [*ancestors, tag_name], current_path)
visit(root, [], "")
existing = {
(issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr"))
for issue in issues
}
unsupported_tag_locations = {
(issue.get("path"), issue.get("tag"))
for issue in issues
if issue.get("code") == "sxsd_unsupported_tag"
}
schema_issues = _validate_sxsd_schema_constraints(xml, root)
missing_attrs_by_location: dict[tuple[str, str], set[str]] = {}
for schema_issue in schema_issues:
if schema_issue.get("code") != "sxsd_missing_required_attr":
continue
location = (schema_issue.get("path"), schema_issue.get("tag"))
missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr"))
suggested_attrs: set[tuple[str, str, str]] = set()
for location, candidate_groups in suggested_attr_candidates.items():
missing_attrs = missing_attrs_by_location.get(location, set())
for candidates in candidate_groups:
matching_missing_attrs = candidates & missing_attrs
if len(matching_missing_attrs) == 1:
suggested_attrs.add((*location, next(iter(matching_missing_attrs))))
for schema_issue in schema_issues:
if schema_issue.get("code") == "sxsd_unexpected_child" and (
schema_issue.get("path"),
schema_issue.get("tag"),
) in unsupported_tag_locations:
continue
if schema_issue.get("code") == "sxsd_missing_required_attr" and (
schema_issue.get("path"),
schema_issue.get("tag"),
schema_issue.get("attr"),
) in suggested_attrs:
continue
key = (
schema_issue.get("code"),
schema_issue.get("path"),
schema_issue.get("tag"),
schema_issue.get("attr"),
)
if key not in existing:
issues.append(schema_issue)
return issues
def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
if re.match(r"^\s*<\?xml\b", xml):
issues.append(
{
"level": "error",
"code": "sxsd_unsupported_declaration",
"path": xml_local_name(root.tag),
"tag": xml_local_name(root.tag),
"expected": "SXSD document without an XML declaration",
"actual": "<?xml ...?>",
"message": "XML declarations are not supported by the Slides SXSD write format",
"hint": "Remove the <?xml ...?> declaration and keep the SXSD root element.",
}
)
issues.extend(
sxsd_validator.validate_sxsd(
without_server_filled_sxsd_fields(root),
SXSD_SCHEMA_PATH,
)
)
return issues
@@ -692,18 +735,39 @@ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
return xml_error
def parse_presentation(xml: str) -> dict[str, Any]:
presentation_match = re.search(r"<presentation\b([^>]*)>", xml)
if presentation_match:
return {
"width": int(float(extract_attribute(presentation_match.group(1), "width") or 960)),
"height": int(float(extract_attribute(presentation_match.group(1), "height") or 540)),
"slides": re.findall(r"<slide\b[\s\S]*?</slide>", xml),
def serialize_slide_for_layout(slide_root: ET.Element) -> str:
slide_copy = copy.deepcopy(slide_root)
for element in slide_copy.iter():
if not isinstance(element.tag, str):
continue
element.tag = xml_local_name(element.tag)
attributes = {
xml_local_name(attribute_name): value
for attribute_name, value in element.attrib.items()
}
slide_match = re.findall(r"<slide\b[\s\S]*?</slide>", xml)
if slide_match:
return {"width": 960, "height": 540, "slides": slide_match}
fail("input must contain a <presentation> or <slide> root")
element.attrib.clear()
element.attrib.update(attributes)
return ET.tostring(slide_copy, encoding="unicode")
def parse_presentation(root: ET.Element) -> dict[str, Any]:
root_name = xml_local_name(root.tag)
if root_name == "slide":
slide_roots = [root]
width = 960
height = 540
elif root_name == "presentation":
slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"]
width = int(float(root.attrib.get("width", 960)))
height = int(float(root.attrib.get("height", 540)))
else:
fail("input must contain a <presentation> or <slide> root")
return {
"width": width,
"height": height,
"slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots],
"slide_roots": slide_roots,
}
def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
@@ -2417,6 +2481,21 @@ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -
return "passed"
def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool:
if issue.get("code") == "sxsd_unsupported_declaration":
return False
if root_name == "slide":
return True
path = issue.get("path")
if not isinstance(path, str):
return False
if path.startswith("presentation/slide/"):
return True
return path == "presentation/slide" and (
issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace"
)
def build_result(
source_path: str | None,
slide_size: dict[str, int | float],
@@ -2472,11 +2551,20 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
raise AssertionError("parse_xml_root must return a root or error")
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root)
root_name = xml_local_name(root.tag)
sxsd_issues = validate_sxsd_document(xml, root)
iconpark_issues = validate_iconpark_icon_types(root)
top_level_issues = [
normalize_issue(issue, None, {})
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
for issue in [
*namespace_issues,
*[
issue
for issue in sxsd_issues
if not is_slide_scoped_sxsd_issue(issue, root_name)
],
*iconpark_issues,
]
]
if any(issue["level"] == "error" for issue in top_level_issues):
return build_result(
@@ -2486,10 +2574,36 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
[],
)
presentation = parse_presentation(xml)
presentation = parse_presentation(root)
slide_roots = presentation["slide_roots"]
slides: list[dict[str, Any]] = []
for index, slide_xml in enumerate(presentation["slides"]):
slide_number = index + 1
slide_root = slide_roots[index]
slide_sxsd_issues = [
normalize_issue(issue, slide_number, {})
for issue in validate_sxsd_document(slide_xml, slide_root)
]
slide_sxsd_errors = [
issue for issue in slide_sxsd_issues if issue["level"] == "error"
]
if slide_sxsd_errors:
slide_sxsd_warnings = [
issue for issue in slide_sxsd_issues if issue["level"] == "warning"
]
slides.append(
{
"slide_number": slide_number,
"status": slide_status(slide_sxsd_errors, slide_sxsd_warnings),
"element_count": 0,
"errors": slide_sxsd_errors,
"warnings": slide_sxsd_warnings,
"infos": [],
"issues": slide_sxsd_issues,
}
)
continue
geometry = lint_slide(
slide_xml,
slide_number,
@@ -2535,8 +2649,11 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
),
]
issues = [
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
*slide_sxsd_issues,
*[
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
],
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"fmt"
"testing"
"time"
@@ -40,7 +41,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
})
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
tableID, _, _ := createTableWithRetry(
tableID, _, primaryViewID := createTableWithRetry(
t,
parentT,
ctx,
@@ -50,6 +51,24 @@ func TestBase_BasicWorkflow(t *testing.T) {
`{"name":"Main","type":"grid"}`,
)
t.Run("resolve table URL as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+url-resolve",
"--url", fmt.Sprintf("https://example.larkoffice.com/base/%s?table=%s&view=%s", baseToken, tableID, primaryViewID),
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, baseToken, gjson.Get(result.Stdout, "data.base_token").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.block_id").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, "table", gjson.Get(result.Stdout, "data.block_type").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table_id").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, primaryViewID, gjson.Get(result.Stdout, "data.view_id").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get table as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestBaseURLResolveSelectedBlockDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+url-resolve",
"--url", "https://example.larkoffice.com/base/app_x?table=blk_selected",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String(), result.Stdout)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", clie2e.DryRunGet(result.Stdout, "api.0.url").String(), result.Stdout)
require.Equal(t, "blk_selected", clie2e.DryRunGet(result.Stdout, "selected_block_id").String(), result.Stdout)
}
func TestBaseURLResolveWikiSelectedBlockDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik_x?table=wkf_selected",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String(), result.Stdout)
require.Equal(t, "/open-apis/wiki/v2/spaces/get_node", clie2e.DryRunGet(result.Stdout, "api.0.url").String(), result.Stdout)
require.Equal(t, "wik_x", clie2e.DryRunGet(result.Stdout, "api.0.params.token").String(), result.Stdout)
require.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.1.method").String(), result.Stdout)
require.Equal(t, "/open-apis/base/v3/bases/%3Cobj_token%20from%20step%201%3E/blocks/list", clie2e.DryRunGet(result.Stdout, "api.1.url").String(), result.Stdout)
require.Equal(t, "wkf_selected", clie2e.DryRunGet(result.Stdout, "selected_block_id").String(), result.Stdout)
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestContactSearchBotWorkflowAsUser proves the live round-trip without assuming
// anything about the tenant's bot inventory. An earlier version required at least
// one match for a hard-coded keyword, which is the tenant dependency that kept
// +search-user out of live coverage (see coverage.md): a tenant with no bot
// matching that word would fail the suite for no reason of ours.
//
// What is tenant-independent and still worth pinning: the command authenticates,
// the server accepts the request, and the envelope keeps its shape. The field
// assertions run over whatever rows came back, so zero rows is a pass.
func TestContactSearchBotWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+search-bot", "--query", "助", "--format", "json"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
bots := gjson.Get(result.Stdout, "data.bots")
require.True(t, bots.IsArray(), "data.bots must be an array even when empty; stdout:\n%s", result.Stdout)
require.True(t, gjson.Get(result.Stdout, "data.has_more").Exists(), "data.has_more must be present; stdout:\n%s", result.Stdout)
for _, bot := range bots.Array() {
openID := bot.Get("open_id").String()
require.NotEmpty(t, openID, "every bot must carry open_id; stdout:\n%s", result.Stdout)
require.True(t, strings.HasPrefix(openID, "ou_"),
"bot ids are open_ids; stdout:\n%s", result.Stdout)
require.True(t, bot.Get("chat_id").Exists(),
"chat_id must be present even when empty; stdout:\n%s", result.Stdout)
require.True(t, bot.Get("match_segments").IsArray(),
"match_segments must be an array, never null; stdout:\n%s", result.Stdout)
}
}
// A filter without a keyword is rejected locally, so this costs no API call and
// holds in any tenant: it pins the contract that neither filter can enumerate.
func TestContactSearchBotRejectsFilterOnlyAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+search-bot", "--has-chatted", "--format", "json"},
DefaultAs: "user",
})
require.NoError(t, err)
require.NotEqual(t, 0, result.ExitCode, "a filter-only request must not succeed; stderr:\n%s", result.Stderr)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
var named []string
for _, p := range gjson.Get(result.Stderr, "error.params").Array() {
named = append(named, p.Get("name").String())
}
require.ElementsMatch(t, []string{"--query", "--queries"}, named,
"the error must name both ways to supply a keyword; stderr:\n%s", result.Stderr)
}

View File

@@ -1,13 +1,15 @@
# Contact CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
- Denominator: 3 leaf commands
- Covered: 2
- Coverage: 66.7%
## Summary
- TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`.
- TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator.
- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates the envelope shape (`bots[]` is an array, `has_more` present) and, for whatever rows the tenant returns, that `open_id` is an `ou_` id, the P2P `chat_id` is present even when empty, and `match_segments` is never null. Deliberately does not require a minimum row count: the assertions must hold in a tenant with no matching bot.
- TestContactSearchBotRejectsFilterOnlyAsUser: pins that `--has-chatted` without a keyword is rejected as a typed validation error naming both `--query` and `--queries`. Rejected locally, so it needs no tenant data and issues no API call.
- Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion.
## Command Table
@@ -15,4 +17,5 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | contact +get-user | shortcut | contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self by open id as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsBot/get user by open id as bot | self lookup; `--user-id <open_id>` | |
| ✓ | contact +search-bot | shortcut | contact_search_bot_workflow_test.go::TestContactSearchBotWorkflowAsUser; contact_search_bot_workflow_test.go::TestContactSearchBotRejectsFilterOnlyAsUser | `--query <keyword>`; `--has-chatted` alone (rejected); `--format json`; user identity | tenant-independent: no minimum row count asserted |
| ✕ | contact +search-user | shortcut | | none | UAT did not reliably return the current user for self-derived queries, so stable write-after-read style proof is not available |

View File

@@ -1,9 +1,9 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 32 leaf commands
- Covered: 13
- Coverage: 40.6%
- Denominator: 41 leaf commands
- Covered: 22
- Coverage: 53.7%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -12,8 +12,12 @@
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveCopyDryRun_DocxURL / TestDriveCopyDryRun_BareTokenBaseAlias / TestDriveCopyDryRun_MySpaceTarget / TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy: dry-run coverage for `drive +copy`; asserts URL parsing to `files/:token/copy`, request body shape (`name`/`type`/`folder_token`, `--extra key=value``extra` key/value array), folder-URL target parsing, `base``bitable` normalization, the `--folder-token my_space` two-step plan (`root_folder/meta -> copy` with a placeholder folder_token), and the typed validation error that redirects wiki inputs to `wiki +node-copy` (hint carries the parsed node token and the `wiki +node-get` space-id lookup).
- TestDrive_CopyWorkflow: proves `drive +copy` against the real backend. Uploads a source file into a temporary Drive folder, copies it with a new name via bare token + `--type file`, asserts the copy returns a distinct token with the requested name, downloads the copy, and verifies the content matches the source; a second copy targets `--folder-token my_space` and asserts the output carries the resolved root token instead of the sentinel. All copies and the folder are removed via best-effort cleanup hooks. The root-folder-meta resolution endpoint (absent from platform metadata) was live-verified for both user and bot identities. Live-probed manually beforehand as user: URL input, output shape, and the wiki redirect were confirmed against the live API. Error codes in the skill reference were also live-probed: nonexistent source token → 1061003, `--type` mismatch (docx declared as sheet) → 1061003 (not 1061002 — the server looks the token up under the declared type).
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_AppsPageURL / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, apps `/page/<token>` URL parsing with `file_type=apps`, default `is_solved=false`, default omitted `is_whole` and `user_id_type`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDrive_CommentOpsDryRun: dry-run coverage for `drive +batch-query-comments`, `drive +resolve-comment`, `drive +restore-comment`, `drive +add-reply`, `drive +list-replies`, `drive +update-reply`, `drive +delete-reply`, and `drive +react-reply`; asserts URL→type inference (incl. Miaoda apps `/page/<token>``file_type=apps` and Base `/base/``file_type=bitable`), `file_type`/`page_size`/`need_reaction`/`need_relation` (docx-gated) query wiring, `comment_ids` / `is_solved` / reply `content.elements[]` (text_run) / reaction `action`+`reaction_type`+`reply_id` body shapes, resolved `:comment_id`/`:reply_id` path segments, the Wiki `get_node -> batch_query` / `get_node -> replies list` / `get_node -> v2 reaction` orchestration plans, and the batch_query wiki dry-run surfacing `need_relation` as the `<sent only when obj_type is docx>` placeholder, without live API calls. All eight verified manually against live documents (list → batch-query → add-reply → list-replies → update-reply → react add/delete → resolve/restore → delete-reply round trip; root-reply update rewriting the comment body, the `1069303 forbidden` cross-identity update rejection, the server persisting arbitrary `reaction_type` strings, and count=0 reaction tombstones were probed live as well).
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDriveCommentOpsWorkflow: opt-in self-contained live workflow for the comment operation shortcuts, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file + file comment fixture, then `+batch-query-comments` finds it by ID, `+add-reply` attaches a reply, `+list-replies` surfaces it, `+update-reply` rewrites its content (confirmed by polling `+list-replies` until the new text lands), `+react-reply` attaches then removes a THUMBSUP reaction (confirmed by polling `+list-replies --need-reaction`, judging presence by count>0 because deleted reactions linger as count=0 entries), `+resolve-comment` marks it solved and `+restore-comment` reopens it (with polling reads between state flips to absorb rate limiting), `+delete-reply --yes` removes the created reply, and cleanup deletes the file.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, converging every delete outcome to the resource-gone terminal state: async deletes (non-empty `task_id`) are verified via `drive +task_result --scenario task_check`, sync deletes (empty `task_id`) assert `deleted=true`, and the one verified backend transient (`server_error: "drive task failed"`) passes once the target is confirmed gone (retried up to 3 times otherwise); any other delete failure stays fatal.
@@ -29,7 +33,16 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_AppsPageURL; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; apps `/page/<token>` URL; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, apps `file_type=apps`, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +batch-query-comments | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; base `/base/` URL (`file_type=bitable`); apps `/page/<token>` URL (`file_type=apps`); `--token + --type wiki`; `--comment-ids` CSV; `--need-reaction` body flag; `--need-relation` body flag (docx only, omitted otherwise) | dry-run pins request shape and wiki resolve plan; opt-in live workflow covers the full comment-ops round trip; need_relation is metadata-absent but live-verified (returns relation with block position) |
| ✓ | drive +resolve-comment | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` sheet; `--comment-id` path segment; fixed `is_solved=true` body | dry-run pins request shape; opt-in live workflow marks the fixture comment solved against the real backend |
| ✓ | drive +restore-comment | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--comment-id` path segment; fixed `is_solved=false` body (same patch endpoint as `+resolve-comment`) | dry-run pins request shape; opt-in live workflow reopens the solved fixture comment against the real backend |
| ✓ | drive +add-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--comment-id` path segment (`.../comments/:comment_id/replies`); `--content` simplified elements → `content.elements[]` `text_run` | dry-run pins reply body mapping; opt-in live workflow attaches and verifies a real reply (body-comment_id create variant does NOT reply — creates a standalone comment) |
| ✓ | drive +list-replies | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--token + --type wiki` resolve plan; `--comment-id` path segment (`.../comments/:comment_id/replies` GET); `--page-size`/`--page-token`/`--need-reaction` query | dry-run pins request shape and wiki resolve plan; opt-in live workflow lists the created reply back (the first-page items[0] is the root reply carrying the comment body) |
| ✓ | drive +update-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | base `/base/` URL (`file_type=bitable`); `--comment-id` + `--reply-id` path segments (PUT); `--content` simplified elements → `content.elements[]` `text_run` | dry-run pins request/body shape; opt-in live workflow rewrites the created reply and polls `+list-replies` until the new text is visible; only the creator identity may update (live-probed `1069303 forbidden`) |
| ✓ | drive +delete-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--comment-id` + `--reply-id` path segments; `--dry-run` bypasses the `--yes` gate | dry-run pins request shape; opt-in live workflow deletes the created reply and verifies the count returns to baseline |
| ✓ | drive +react-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--token + --type wiki` resolve plan; v2 `.../comments/reaction` POST; `--reply-id`/`--emoji`/`--action add\|delete` → body `reply_id`/`reaction_type`/`action` | dry-run pins request/body shape and wiki resolve plan; opt-in live workflow adds then removes a reaction, polling `+list-replies --need-reaction` with count>0 presence checks; local `--emoji` enum validation guards the unvalidated server field |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✓ | drive +copy | shortcut | drive_copy_dryrun_test.go::TestDriveCopyDryRun_DocxURL; drive_copy_dryrun_test.go::TestDriveCopyDryRun_BareTokenBaseAlias; drive_copy_dryrun_test.go::TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy; drive_copy_workflow_test.go::TestDrive_CopyWorkflow | `--url` doc URL vs bare `--token + --type`; `--type base` alias; `--name` body; `--folder-token` URL vs bare vs `my_space` sentinel; `--extra key=value` passthrough; wiki URL/token redirect to `wiki +node-copy` | dry-run locks request shape, the my_space two-step plan, and wiki redirect guidance; live workflow copies an uploaded file, verifies content via download, and copies into the resolved My Space root; `--extra` body shape confirmed against the live API (docx copy with `target_type=docx`) |
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
@@ -53,7 +66,7 @@
| ✕ | drive file.comments patch | api | | none | no file comment workflow yet |
| ✕ | drive file.statistics get | api | | none | no statistics workflow yet |
| ✕ | drive file.view_records list | api | | none | no view-record workflow yet |
| ✕ | drive files copy | api | | none | no file copy workflow yet |
| ✕ | drive files copy | api | | none | endpoint exercised live through `drive +copy` (TestDrive_CopyWorkflow); the raw service command itself has no workflow |
| ✓ | drive files create_folder | api | drive_files_workflow_test.go::TestDrive_FilesCreateFolderWorkflow/create_folder as bot | `name`; empty `folder_token` in `--data` | |
| ✕ | drive files list | api | | none | no list workflow yet |
| ✕ | drive metas batch_query | api | | none | no metadata workflow yet |

View File

@@ -0,0 +1,347 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestDrive_CommentOpsDryRun pins the request contracts of the comment
// operation shortcuts (+batch-query-comments, +resolve-comment,
// +restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply,
// +react-reply) without hitting live APIs.
func TestDrive_CommentOpsDryRun(t *testing.T) {
setDriveDryRunConfigEnv(t)
tests := []struct {
name string
args []string
wantMethod string
wantURL string
assert func(t *testing.T, out string)
}{
{
name: "batch query comments by docx url",
args: []string{
"drive", "+batch-query-comments",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment?from=share",
"--comment-ids", "7457001,7457002",
"--need-reaction",
"--need-relation",
"--dry-run",
},
wantMethod: "POST",
wantURL: "/open-apis/drive/v1/files/doxcnE2EComment/comments/batch_query",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("file_type = %q, want docx\nstdout:\n%s", got, out)
}
ids := clie2e.DryRunGet(out, "api.0.body.comment_ids")
if len(ids.Array()) != 2 || ids.Array()[0].String() != "7457001" {
t.Fatalf("comment_ids = %v, want [7457001 7457002]\nstdout:\n%s", ids.Value(), out)
}
if got := clie2e.DryRunGet(out, "api.0.body.need_reaction").Bool(); !got {
t.Fatalf("need_reaction = %v, want true\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.need_relation").Bool(); !got {
t.Fatalf("need_relation = %v, want true for docx\nstdout:\n%s", got, out)
}
},
},
{
name: "batch query comments by wiki token plans resolve first",
args: []string{
"drive", "+batch-query-comments",
"--token", "wikcnE2EComment",
"--type", "wiki",
"--comment-ids", "7457001",
"--need-relation",
"--dry-run",
},
wantMethod: "GET",
wantURL: "/open-apis/wiki/v2/spaces/get_node",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.token").String(); got != "wikcnE2EComment" {
t.Fatalf("wiki token = %q, want wikcnE2EComment\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "POST" {
t.Fatalf("api.1.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments/batch_query" {
t.Fatalf("api.1.url = %q, want placeholder batch_query URL\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.body.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.body.need_relation = %q, want conditional placeholder\nstdout:\n%s", got, out)
}
},
},
{
name: "batch query comments on miaoda apps page url",
args: []string{
"drive", "+batch-query-comments",
"--url", "https://example.feishu.cn/page/N1BWmE2EAppsPage/",
"--comment-ids", "7457001",
"--dry-run",
},
wantMethod: "POST",
wantURL: "/open-apis/drive/v1/files/N1BWmE2EAppsPage/comments/batch_query",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "apps" {
t.Fatalf("file_type = %q, want apps\nstdout:\n%s", got, out)
}
},
},
{
name: "batch query comments on base url",
args: []string{
"drive", "+batch-query-comments",
"--url", "https://example.feishu.cn/base/bascnE2EComment",
"--comment-ids", "7457001",
"--need-relation",
"--dry-run",
},
wantMethod: "POST",
wantURL: "/open-apis/drive/v1/files/bascnE2EComment/comments/batch_query",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "bitable" {
t.Fatalf("file_type = %q, want bitable\nstdout:\n%s", got, out)
}
if clie2e.DryRunGet(out, "api.0.body.need_relation").Exists() {
t.Fatalf("need_relation must be omitted for non-docx targets\nstdout:\n%s", out)
}
},
},
{
name: "resolve comment on sheet url",
args: []string{
"drive", "+resolve-comment",
"--url", "https://example.feishu.cn/sheets/shtcnE2EComment",
"--comment-id", "7457001",
"--dry-run",
},
wantMethod: "PATCH",
wantURL: "/open-apis/drive/v1/files/shtcnE2EComment/comments/7457001",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "sheet" {
t.Fatalf("file_type = %q, want sheet\nstdout:\n%s", got, out)
}
isSolved := clie2e.DryRunGet(out, "api.0.body.is_solved")
if !isSolved.Exists() || !isSolved.Bool() {
t.Fatalf("is_solved = %v, want true\nstdout:\n%s", isSolved.Value(), out)
}
},
},
{
name: "restore comment sends is_solved false",
args: []string{
"drive", "+restore-comment",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment",
"--comment-id", "7457001",
"--dry-run",
},
wantMethod: "PATCH",
wantURL: "/open-apis/drive/v1/files/doxcnE2EComment/comments/7457001",
assert: func(t *testing.T, out string) {
isSolved := clie2e.DryRunGet(out, "api.0.body.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("is_solved = %v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
},
},
{
name: "add reply to comment on docx url",
args: []string{
"drive", "+add-reply",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment",
"--comment-id", "7457001",
"--content", `[{"type":"text","text":"e2e reply"}]`,
"--dry-run",
},
wantMethod: "POST",
wantURL: "/open-apis/drive/v1/files/doxcnE2EComment/comments/7457001/replies",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("file_type = %q, want docx\nstdout:\n%s", got, out)
}
if clie2e.DryRunGet(out, "api.0.body.comment_id").Exists() {
t.Fatalf("body.comment_id must be absent (rides in URL path)\nstdout:\n%s", out)
}
if got := clie2e.DryRunGet(out, "api.0.body.content.elements.0.type").String(); got != "text_run" {
t.Fatalf("element type = %q, want text_run\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.content.elements.0.text_run.text").String(); got != "e2e reply" {
t.Fatalf("element text = %q, want e2e reply\nstdout:\n%s", got, out)
}
},
},
{
name: "list replies on docx url",
args: []string{
"drive", "+list-replies",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment",
"--comment-id", "7457001",
"--page-size", "20",
"--need-reaction",
"--dry-run",
},
wantMethod: "GET",
wantURL: "/open-apis/drive/v1/files/doxcnE2EComment/comments/7457001/replies",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("file_type = %q, want docx\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.page_size").Int(); got != 20 {
t.Fatalf("page_size = %d, want 20\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.need_reaction").Bool(); !got {
t.Fatalf("need_reaction = %v, want true\nstdout:\n%s", got, out)
}
if clie2e.DryRunGet(out, "api.0.params.user_id_type").Exists() {
t.Fatalf("user_id_type must be omitted (flag removed)\nstdout:\n%s", out)
}
},
},
{
name: "list replies by wiki token plans resolve first",
args: []string{
"drive", "+list-replies",
"--token", "wikcnE2EComment",
"--type", "wiki",
"--comment-id", "7457001",
"--dry-run",
},
wantMethod: "GET",
wantURL: "/open-apis/wiki/v2/spaces/get_node",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "GET" {
t.Fatalf("api.1.method = %q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments/7457001/replies" {
t.Fatalf("api.1.url = %q, want placeholder replies URL\nstdout:\n%s", got, out)
}
},
},
{
name: "update reply on base url",
args: []string{
"drive", "+update-reply",
"--url", "https://example.feishu.cn/base/bascnE2EComment",
"--comment-id", "7457001",
"--reply-id", "7457002",
"--content", `[{"type":"text","text":"e2e updated reply"}]`,
"--dry-run",
},
wantMethod: "PUT",
wantURL: "/open-apis/drive/v1/files/bascnE2EComment/comments/7457001/replies/7457002",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "bitable" {
t.Fatalf("file_type = %q, want bitable\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.content.elements.0.type").String(); got != "text_run" {
t.Fatalf("element type = %q, want text_run\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.content.elements.0.text_run.text").String(); got != "e2e updated reply" {
t.Fatalf("element text = %q, want e2e updated reply\nstdout:\n%s", got, out)
}
},
},
{
name: "react reply add on docx url",
args: []string{
"drive", "+react-reply",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment",
"--reply-id", "7457002",
"--emoji", "THUMBSUP",
"--action", "add",
"--dry-run",
},
wantMethod: "POST",
wantURL: "/open-apis/drive/v2/files/doxcnE2EComment/comments/reaction",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("file_type = %q, want docx\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.action").String(); got != "add" {
t.Fatalf("body.action = %q, want add\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.reaction_type").String(); got != "THUMBSUP" {
t.Fatalf("body.reaction_type = %q, want THUMBSUP\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.reply_id").String(); got != "7457002" {
t.Fatalf("body.reply_id = %q, want 7457002\nstdout:\n%s", got, out)
}
},
},
{
name: "react reply delete by wiki token plans resolve first",
args: []string{
"drive", "+react-reply",
"--token", "wikcnE2EComment",
"--type", "wiki",
"--reply-id", "7457002",
"--emoji", "OK",
"--action", "delete",
"--dry-run",
},
wantMethod: "GET",
wantURL: "/open-apis/wiki/v2/spaces/get_node",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "POST" {
t.Fatalf("api.1.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v2/files/<obj_token from step 1>/comments/reaction" {
t.Fatalf("api.1.url = %q, want placeholder reaction URL\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.body.action").String(); got != "delete" {
t.Fatalf("api.1.body.action = %q, want delete\nstdout:\n%s", got, out)
}
},
},
{
name: "delete reply on docx url",
args: []string{
"drive", "+delete-reply",
"--url", "https://example.feishu.cn/docx/doxcnE2EComment",
"--comment-id", "7457001",
"--reply-id", "7457002",
"--dry-run",
},
wantMethod: "DELETE",
wantURL: "/open-apis/drive/v1/files/doxcnE2EComment/comments/7457001/replies/7457002",
assert: func(t *testing.T, out string) {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("file_type = %q, want docx\nstdout:\n%s", got, out)
}
},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != tt.wantMethod {
t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
tt.assert(t, out)
})
}
}

View File

@@ -0,0 +1,367 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDriveCommentOpsWorkflow proves the comment-operation shortcuts
// (+batch-query-comments, +add-reply, +list-replies, +update-reply,
// +react-reply, +resolve-comment, +restore-comment, +delete-reply) against
// the live API in one self-contained flow, sharing the
// LARK_DRIVE_MD_COMMENT_E2E gate with the file-comment workflow in
// drive_add_comment_workflow_test.go (both write comments on a temporary
// supported file).
//
// Sequencing matters: the reply is created before resolving because solved
// comments reject replies, and state flips are separated by polling reads
// because back-to-back PATCHes on one comment can hit rate limiting.
func TestDriveCommentOpsWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the comment operations workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-drive-comment-ops-" + suffix + ".md"
// --- Create: fixture file + fixture comment ---
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# Comment ops target\n\nbody\n",
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete comment ops target "+fileToken, deleteResult, deleteErr)
})
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", fileToken,
"--type", "file",
"--content", `[{"type":"text","text":"comment ops fixture"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
commentResult.AssertExitCode(t, 0)
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
// --- Use: +batch-query-comments finds the fixture by ID ---
batchArgs := []string{
"drive", "+batch-query-comments",
"--token", fileToken,
"--type", "file",
"--comment-ids", commentID,
}
batchResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: batchArgs,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
},
})
require.NoError(t, err)
batchResult.AssertExitCode(t, 0)
require.True(t, driveCommentListContainsID(batchResult.Stdout, commentID), "stdout:\n%s", batchResult.Stdout)
if got := gjson.Get(batchResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("batch data.file_type=%q, want file\nstdout:\n%s", got, batchResult.Stdout)
}
fixture := driveCommentOpsItem(batchResult.Stdout, commentID)
require.False(t, fixture.Get("is_solved").Bool(), "fixture must start unsolved\nstdout:\n%s", batchResult.Stdout)
baseReplies := len(fixture.Get("reply_list.replies").Array())
// --- Use: +add-reply attaches a reply under the fixture comment ---
replyResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-reply",
"--token", fileToken,
"--type", "file",
"--comment-id", commentID,
"--content", `[{"type":"text","text":"comment ops reply"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
replyResult.AssertExitCode(t, 0)
replyID := gjson.Get(replyResult.Stdout, "data.reply_id").String()
require.NotEmpty(t, replyID, "stdout:\n%s", replyResult.Stdout)
driveCommentOpsAwaitReplies(t, ctx, batchArgs, commentID, baseReplies+1)
// --- Use: +list-replies surfaces the created reply ---
listRepliesArgs := []string{
"drive", "+list-replies",
"--token", fileToken,
"--type", "file",
"--comment-id", commentID,
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: listRepliesArgs,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentOpsReplyItem(result.Stdout, replyID).Exists()
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
require.True(t, driveCommentOpsReplyItem(listResult.Stdout, replyID).Exists(), "stdout:\n%s", listResult.Stdout)
if got := gjson.Get(listResult.Stdout, "data.comment_id").String(); got != commentID {
t.Fatalf("list data.comment_id=%q, want %s\nstdout:\n%s", got, commentID, listResult.Stdout)
}
// --- Use: +update-reply rewrites the created reply's content ---
updatedText := "comment ops reply updated " + suffix
updateResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+update-reply",
"--token", fileToken,
"--type", "file",
"--comment-id", commentID,
"--reply-id", replyID,
"--content", `[{"type":"text","text":"` + updatedText + `"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0
},
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
require.True(t, gjson.Get(updateResult.Stdout, "data.updated").Bool(), "stdout:\n%s", updateResult.Stdout)
driveCommentOpsAwaitReplyText(t, ctx, listRepliesArgs, replyID, updatedText)
// --- Use: +react-reply attaches and removes an emoji reaction ---
driveCommentOpsReact(t, ctx, fileToken, replyID, "add")
driveCommentOpsAwaitReaction(t, ctx, listRepliesArgs, replyID, "THUMBSUP", true)
driveCommentOpsReact(t, ctx, fileToken, replyID, "delete")
driveCommentOpsAwaitReaction(t, ctx, listRepliesArgs, replyID, "THUMBSUP", false)
// --- Use: +resolve-comment flips is_solved both ways ---
driveCommentOpsPatchSolved(t, ctx, fileToken, commentID, "resolve")
driveCommentOpsAwaitSolved(t, ctx, batchArgs, commentID, true)
driveCommentOpsPatchSolved(t, ctx, fileToken, commentID, "restore")
driveCommentOpsAwaitSolved(t, ctx, batchArgs, commentID, false)
// --- Use: +delete-reply removes exactly the created reply ---
deleteReplyResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+delete-reply",
"--token", fileToken,
"--type", "file",
"--comment-id", commentID,
"--reply-id", replyID,
"--yes",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
deleteReplyResult.AssertExitCode(t, 0)
require.True(t, gjson.Get(deleteReplyResult.Stdout, "data.deleted").Bool(), "stdout:\n%s", deleteReplyResult.Stdout)
driveCommentOpsAwaitReplies(t, ctx, batchArgs, commentID, baseReplies)
}
// driveCommentOpsItem returns the batch-query item for commentID (zero Result
// if absent).
func driveCommentOpsItem(stdout, commentID string) gjson.Result {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("comment_id").String() == commentID {
return item
}
}
return gjson.Result{}
}
// driveCommentOpsReplyItem returns the +list-replies item for replyID (zero
// Result if absent).
func driveCommentOpsReplyItem(stdout, replyID string) gjson.Result {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("reply_id").String() == replyID {
return item
}
}
return gjson.Result{}
}
// driveCommentOpsReact runs +react-reply with the given action, retrying on
// non-zero exits (writes on one comment card can be rate limited).
func driveCommentOpsReact(t *testing.T, ctx context.Context, fileToken, replyID, action string) {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+react-reply",
"--token", fileToken,
"--type", "file",
"--reply-id", replyID,
"--emoji", "THUMBSUP",
"--action", action,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.True(t, gjson.Get(result.Stdout, "data.updated").Bool(), "stdout:\n%s", result.Stdout)
}
// driveCommentOpsAwaitReaction polls +list-replies --need-reaction until the
// reply's reaction of the given key is present (count>0) or gone. Entries
// with count=0 linger after deletion, so presence is judged by count.
func driveCommentOpsAwaitReaction(t *testing.T, ctx context.Context, listArgs []string, replyID, reactionKey string, want bool) {
t.Helper()
args := append(append([]string{}, listArgs...), "--need-reaction")
hasReaction := func(stdout string) bool {
for _, reaction := range driveCommentOpsReplyItem(stdout, replyID).Get("reactions").Array() {
if reaction.Get("reaction_key").String() == reactionKey && reaction.Get("count").Int() > 0 {
return true
}
}
return false
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || hasReaction(result.Stdout) != want
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, want, hasReaction(result.Stdout), "stdout:\n%s", result.Stdout)
}
// driveCommentOpsAwaitReplyText polls +list-replies until replyID carries the
// wanted text_run text.
func driveCommentOpsAwaitReplyText(t *testing.T, ctx context.Context, listArgs []string, replyID, wantText string) {
t.Helper()
replyText := func(stdout string) string {
return driveCommentOpsReplyItem(stdout, replyID).Get("content.elements.0.text_run.text").String()
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: listArgs,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || replyText(result.Stdout) != wantText
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, wantText, replyText(result.Stdout), "stdout:\n%s", result.Stdout)
}
// driveCommentOpsPatchSolved runs +resolve-comment or +restore-comment,
// retrying on non-zero exits (consecutive PATCHes on one comment can be rate
// limited).
func driveCommentOpsPatchSolved(t *testing.T, ctx context.Context, fileToken, commentID, action string) {
t.Helper()
command := "+resolve-comment"
if action == "restore" {
command = "+restore-comment"
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", command,
"--token", fileToken,
"--type", "file",
"--comment-id", commentID,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.True(t, gjson.Get(result.Stdout, "data.updated").Bool(), "stdout:\n%s", result.Stdout)
}
// driveCommentOpsAwaitSolved polls batch-query until the fixture comment
// reports the wanted is_solved state.
func driveCommentOpsAwaitSolved(t *testing.T, ctx context.Context, batchArgs []string, commentID string, want bool) {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: batchArgs,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
item := driveCommentOpsItem(result.Stdout, commentID)
return !item.Exists() || item.Get("is_solved").Bool() != want
},
})
require.NoError(t, err)
item := driveCommentOpsItem(result.Stdout, commentID)
require.True(t, item.Exists(), "stdout:\n%s", result.Stdout)
require.Equal(t, want, item.Get("is_solved").Bool(), "stdout:\n%s", result.Stdout)
}
// driveCommentOpsAwaitReplies polls batch-query until the fixture comment
// carries the wanted reply count.
func driveCommentOpsAwaitReplies(t *testing.T, ctx context.Context, batchArgs []string, commentID string, want int) {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: batchArgs,
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
item := driveCommentOpsItem(result.Stdout, commentID)
return !item.Exists() || len(item.Get("reply_list.replies").Array()) != want
},
})
require.NoError(t, err)
item := driveCommentOpsItem(result.Stdout, commentID)
require.True(t, item.Exists(), "stdout:\n%s", result.Stdout)
require.Len(t, item.Get("reply_list.replies").Array(), want, "stdout:\n%s", result.Stdout)
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestDriveCopyDryRun_DocxURL(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", "+copy",
"--url", "https://example.larksuite.com/docx/docxDryRunCopy?from=share",
"--name", "Copied doc",
"--folder-token", "https://example.larksuite.com/drive/folder/folderDryRunCopy",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("api.0.method=%q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" {
t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.name").String(); got != "Copied doc" {
t.Fatalf("api.0.body.name=%q, want Copied doc\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "docx" {
t.Fatalf("api.0.body.type=%q, want docx\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.folder_token").String(); got != "folderDryRunCopy" {
t.Fatalf("api.0.body.folder_token=%q, want token parsed from folder URL\nstdout:\n%s", got, out)
}
}
func TestDriveCopyDryRun_BareTokenBaseAlias(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", "+copy",
"--token", "bitableDryRunCopy",
"--type", "base",
"--name", "Copied base",
"--folder-token", "folderDryRunCopy",
"--extra", "target_type=bitable",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/bitableDryRunCopy/copy" {
t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "bitable" {
t.Fatalf("api.0.body.type=%q, want bitable (base alias normalized)\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.extra.0.key").String(); got != "target_type" {
t.Fatalf("api.0.body.extra.0.key=%q, want target_type\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.body.extra.0.value").String(); got != "bitable" {
t.Fatalf("api.0.body.extra.0.value=%q, want bitable\nstdout:\n%s", got, out)
}
}
func TestDriveCopyDryRun_MySpaceTarget(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", "+copy",
"--url", "https://example.larksuite.com/docx/docxDryRunCopy",
"--name", "Copied doc",
"--folder-token", "my_space",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/explorer/v2/root_folder/meta" {
t.Fatalf("api.0.url=%q, want root folder meta\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" {
t.Fatalf("api.1.url=%q, want copy endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.body.folder_token").String(); got != "<root folder token from step 1>" {
t.Fatalf("api.1.body.folder_token=%q, want placeholder\nstdout:\n%s", got, out)
}
}
func TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy(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", "+copy",
"--url", "https://example.larksuite.com/wiki/wikiDryRunCopy",
"--name", "Copied wiki",
"--folder-token", "folderDryRunCopy",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("wiki URL should be rejected with a redirect error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if !strings.Contains(result.Stderr, `"type": "validation"`) {
t.Fatalf("stderr should carry a typed validation error\nstderr:\n%s", result.Stderr)
}
if !strings.Contains(result.Stderr, "wiki +node-copy --space-id") ||
!strings.Contains(result.Stderr, "--node-token wikiDryRunCopy") {
t.Fatalf("stderr should guide to wiki +node-copy with the parsed node token\nstderr:\n%s", result.Stderr)
}
if !strings.Contains(result.Stderr, "wiki +node-get --token wikiDryRunCopy") {
t.Fatalf("stderr should explain how to resolve the space id\nstderr:\n%s", result.Stderr)
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_CopyWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-copy-"+suffix, "")
workDir := t.TempDir()
scheduleDelete := func(fileToken string) {
t.Helper()
if fileToken == "" {
return
}
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
}
sourceContent := "drive copy e2e: source content\n"
sourcePath := filepath.Join(workDir, "copy-source.txt")
if err := os.WriteFile(sourcePath, []byte(sourceContent), 0o644); err != nil {
t.Fatalf("write source file: %v", err)
}
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", "copy-source.txt",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
uploadResult.AssertExitCode(t, 0)
uploadResult.AssertStdoutStatus(t, true)
sourceToken := gjson.Get(uploadResult.Stdout, "data.file_token").String()
require.NotEmpty(t, sourceToken, "uploaded source should have a token, stdout:\n%s", uploadResult.Stdout)
scheduleDelete(sourceToken)
copyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+copy",
"--token", sourceToken,
"--type", "file",
"--name", "copy-result.txt",
"--folder-token", folderToken,
},
DefaultAs: "bot",
})
require.NoError(t, err)
copyResult.AssertExitCode(t, 0)
copyResult.AssertStdoutStatus(t, true)
copiedToken := gjson.Get(copyResult.Stdout, "data.file_token").String()
require.NotEmpty(t, copiedToken, "copy should return the new file token, stdout:\n%s", copyResult.Stdout)
scheduleDelete(copiedToken)
if copiedToken == sourceToken {
t.Fatalf("copied token should differ from source token %q\nstdout:\n%s", sourceToken, copyResult.Stdout)
}
if got := gjson.Get(copyResult.Stdout, "data.name").String(); got != "copy-result.txt" {
t.Fatalf("data.name=%q, want copy-result.txt\nstdout:\n%s", got, copyResult.Stdout)
}
if got := gjson.Get(copyResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, copyResult.Stdout)
}
if got := gjson.Get(copyResult.Stdout, "data.source_file_token").String(); got != sourceToken {
t.Fatalf("data.source_file_token=%q, want %q\nstdout:\n%s", got, sourceToken, copyResult.Stdout)
}
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", copiedToken,
"--output", "copy-downloaded.txt",
"--overwrite",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
mySpaceResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+copy",
"--token", sourceToken,
"--type", "file",
"--name", "copy-result-my-space.txt",
"--folder-token", "my_space",
},
DefaultAs: "bot",
})
require.NoError(t, err)
mySpaceResult.AssertExitCode(t, 0)
mySpaceResult.AssertStdoutStatus(t, true)
mySpaceToken := gjson.Get(mySpaceResult.Stdout, "data.file_token").String()
require.NotEmpty(t, mySpaceToken, "my_space copy should return the new file token, stdout:\n%s", mySpaceResult.Stdout)
scheduleDelete(mySpaceToken)
if got := gjson.Get(mySpaceResult.Stdout, "data.folder_token").String(); got == "" || got == "my_space" {
t.Fatalf("data.folder_token=%q, want the resolved My Space root token\nstdout:\n%s", got, mySpaceResult.Stdout)
}
data, err := os.ReadFile(filepath.Join(workDir, "copy-downloaded.txt"))
if err != nil {
t.Fatalf("read downloaded copy: %v", err)
}
if string(data) != sourceContent {
t.Fatalf("copied content=%q want %q", string(data), sourceContent)
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package dryrun
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestContactSearchBotDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "contact_search_bot_dryrun")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "contact_search_bot_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"contact", "+search-bot",
"--query", "助手",
"--chat-ids", "oc_a,oc_b",
"--has-chatted",
"--page-size", "25",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/bot/v4/bot/search", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int(), "stdout:\n%s", out)
require.Equal(t, "助手", clie2e.DryRunGet(out, "api.0.body.query").String(), "stdout:\n%s", out)
require.Equal(t, []string{"oc_a", "oc_b"}, []string{
clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.0").String(),
clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.1").String(),
}, "stdout:\n%s", out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.filter.has_chatter").Bool(), "stdout:\n%s", out)
}