mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
7 Commits
feat/plugi
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c906b9415 | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
447
shortcuts/contact/contact_search_bot.go
Normal file
447
shortcuts/contact/contact_search_bot.go
Normal 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
|
||||
}
|
||||
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
289
shortcuts/contact/contact_search_bot_fanout.go
Normal 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
|
||||
}
|
||||
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
724
shortcuts/contact/contact_search_bot_test.go
Normal file
724
shortcuts/contact/contact_search_bot_test.go
Normal 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>部门成员&仓库\n来自飞书多维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
|
||||
{name: "html entity in highlight", raw: "名称<h>&</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: "名称<h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "escaped angle brackets inside a highlight", raw: "<h>名称<h></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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ContactSearchUser,
|
||||
ContactSearchBot,
|
||||
ContactGetUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
212
shortcuts/drive/drive_add_reply.go
Normal file
212
shortcuts/drive/drive_add_reply.go
Normal 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)
|
||||
}
|
||||
393
shortcuts/drive/drive_add_reply_test.go
Normal file
393
shortcuts/drive/drive_add_reply_test.go
Normal 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<b" {
|
||||
t.Fatalf("elements[0].text_run.text = %#v, want escaped a<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)
|
||||
}
|
||||
}
|
||||
175
shortcuts/drive/drive_batch_query_comments.go
Normal file
175
shortcuts/drive/drive_batch_query_comments.go
Normal 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)
|
||||
}
|
||||
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal file
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
246
shortcuts/drive/drive_comment_common.go
Normal file
246
shortcuts/drive/drive_comment_common.go
Normal 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
|
||||
}
|
||||
268
shortcuts/drive/drive_comment_common_test.go
Normal file
268
shortcuts/drive/drive_comment_common_test.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
358
shortcuts/drive/drive_copy.go
Normal file
358
shortcuts/drive/drive_copy.go
Normal 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
|
||||
}
|
||||
811
shortcuts/drive/drive_copy_test.go
Normal file
811
shortcuts/drive/drive_copy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
134
shortcuts/drive/drive_delete_reply.go
Normal file
134
shortcuts/drive/drive_delete_reply.go
Normal 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)
|
||||
}
|
||||
279
shortcuts/drive/drive_delete_reply_test.go
Normal file
279
shortcuts/drive/drive_delete_reply_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
156
shortcuts/drive/drive_list_replies.go
Normal file
156
shortcuts/drive/drive_list_replies.go
Normal 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)
|
||||
}
|
||||
425
shortcuts/drive/drive_list_replies_test.go
Normal file
425
shortcuts/drive/drive_list_replies_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
215
shortcuts/drive/drive_react_reply.go
Normal file
215
shortcuts/drive/drive_react_reply.go
Normal 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)
|
||||
}
|
||||
386
shortcuts/drive/drive_react_reply_test.go
Normal file
386
shortcuts/drive/drive_react_reply_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
168
shortcuts/drive/drive_resolve_comment.go
Normal file
168
shortcuts/drive/drive_resolve_comment.go
Normal 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)
|
||||
}
|
||||
376
shortcuts/drive/drive_resolve_comment_test.go
Normal file
376
shortcuts/drive/drive_resolve_comment_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
152
shortcuts/drive/drive_update_reply.go
Normal file
152
shortcuts/drive/drive_update_reply.go
Normal 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)
|
||||
}
|
||||
356
shortcuts/drive/drive_update_reply_test.go
Normal file
356
shortcuts/drive/drive_update_reply_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -79,16 +79,23 @@ lark-cli base +data-query \
|
||||
| `--base-token <token>` | 是 | Base Token(base_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 入参详情
|
||||
|
||||
|
||||
@@ -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 文档 |
|
||||
|
||||
## 写操作反馈
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
|
||||
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
|
||||
| 「就改这一次」「只删这一次」 | 仅此次 |
|
||||
| 「给明天的日程加个会议室」(且为重复日程) | 范围不明确,**必须询问用户** |
|
||||
| 未明确范围 | **必须询问用户** |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -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 范围
|
||||
|
||||
|
||||
60
skills/lark-contact/references/lark-contact-search-bot.md
Normal file
60
skills/lark-contact/references/lark-contact-search-bot.md
Normal 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>` | 返回条数,1–30,默认 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` 对所有关键词生效
|
||||
@@ -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`。
|
||||
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `<`,`>` -> `>`。
|
||||
- 使用 `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 token(base 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
|
||||
|
||||
|
||||
@@ -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` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。
|
||||
- **所有 `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`。
|
||||
|
||||
47
skills/lark-drive/references/lark-drive-add-reply.md
Normal file
47
skills/lark-drive/references/lark-drive-add-reply.md
Normal 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 URL;apps 妙搭 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) -- 获取回复
|
||||
@@ -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 URL;apps 妙搭 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` 评论定位
|
||||
50
skills/lark-drive/references/lark-drive-comment-content.md
Normal file
50
skills/lark-drive/references/lark-drive-comment-content.md
Normal 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) -- 更新回复
|
||||
@@ -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 params,batch_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 params,batch_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 字符串。
|
||||
|
||||
@@ -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`。
|
||||
- 评论写入内容里的文本不能直接出现 `<`、`>`;提交前应转义为 `<`、`>`。`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":"全文评论内容"}]}'
|
||||
```
|
||||
62
skills/lark-drive/references/lark-drive-copy.md
Normal file
62
skills/lark-drive/references/lark-drive-copy.md
Normal 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) -- 认证和全局参数
|
||||
48
skills/lark-drive/references/lark-drive-delete-reply.md
Normal file
48
skills/lark-drive/references/lark-drive-delete-reply.md
Normal 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 URL;apps 妙搭 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
|
||||
@@ -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 正文
|
||||
|
||||
54
skills/lark-drive/references/lark-drive-list-replies.md
Normal file
54
skills/lark-drive/references/lark-drive-list-replies.md
Normal 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 URL;apps 妙搭 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 查询与写入
|
||||
51
skills/lark-drive/references/lark-drive-react-reply.md
Normal file
51
skills/lark-drive/references/lark-drive-react-reply.md
Normal 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 URL;apps 妙搭 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
|
||||
@@ -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 reaction(wiki 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) -- 认证和全局参数
|
||||
|
||||
45
skills/lark-drive/references/lark-drive-resolve-comment.md
Normal file
45
skills/lark-drive/references/lark-drive-resolve-comment.md
Normal 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 URL;apps 妙搭 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) -- 恢复(重新打开)评论
|
||||
46
skills/lark-drive/references/lark-drive-restore-comment.md
Normal file
46
skills/lark-drive/references/lark-drive-restore-comment.md
Normal 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 URL;apps 妙搭 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) -- 解决(标记已解决)评论
|
||||
46
skills/lark-drive/references/lark-drive-update-reply.md
Normal file
46
skills/lark-drive/references/lark-drive-update-reply.md
Normal 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 URL;apps 妙搭 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
|
||||
@@ -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 的特殊约定。
|
||||
|
||||
908
skills/lark-slides/scripts/sxsd_validator.py
Normal file
908
skills/lark-slides/scripts/sxsd_validator.py
Normal 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
|
||||
@@ -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
@@ -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},
|
||||
|
||||
62
tests/cli_e2e/base/base_url_resolve_dryrun_test.go
Normal file
62
tests/cli_e2e/base/base_url_resolve_dryrun_test.go
Normal 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)
|
||||
}
|
||||
76
tests/cli_e2e/contact/contact_search_bot_workflow_test.go
Normal file
76
tests/cli_e2e/contact/contact_search_bot_workflow_test.go
Normal 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)
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
347
tests/cli_e2e/drive/drive_comment_ops_dryrun_test.go
Normal file
347
tests/cli_e2e/drive/drive_comment_ops_dryrun_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
367
tests/cli_e2e/drive/drive_comment_ops_workflow_test.go
Normal file
367
tests/cli_e2e/drive/drive_comment_ops_workflow_test.go
Normal 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)
|
||||
}
|
||||
153
tests/cli_e2e/drive/drive_copy_dryrun_test.go
Normal file
153
tests/cli_e2e/drive/drive_copy_dryrun_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
138
tests/cli_e2e/drive/drive_copy_workflow_test.go
Normal file
138
tests/cli_e2e/drive/drive_copy_workflow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
48
tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go
Normal file
48
tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user