From 71458c36808c82801fc60ba2b30ebf0b3e2bc7d8 Mon Sep 17 00:00:00 2001 From: jackie3927 Date: Sat, 29 Aug 2026 15:11:46 +0800 Subject: [PATCH 1/3] feat: expand folder one level in message content with children_count hint When message content contains a folder (messages-mget / chat-messages-list / threads-messages-list), folderConverter now calls GET /open-apis/im/v1/resources/:file_key/children (srctype=message&srcid=msgid&recursive=false) to expand one level, outputting first-level files/subfolders with children_count hint for deeper levels (recursive=true to expand fully). Falls back to legacy output when Runtime/MessageID missing or API unavailable. --- shortcuts/im/convert_lib/misc.go | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/shortcuts/im/convert_lib/misc.go b/shortcuts/im/convert_lib/misc.go index 15a9f1f378..5ee6e67a77 100644 --- a/shortcuts/im/convert_lib/misc.go +++ b/shortcuts/im/convert_lib/misc.go @@ -5,8 +5,12 @@ package convertlib import ( "fmt" + "net/http" "regexp" "strings" + + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) type stickerConverter struct{} @@ -72,12 +76,55 @@ func (folderConverter) Convert(ctx *ConvertContext) string { return "[Folder]" } name, _ := parsed["file_name"].(string) + + // 展开一层:调 openapi children(recursive=false),输出第一层 + children_count + 深层提示 + // 需要 Runtime + MessageID(srctype=message&srcid=MessageID);不可用时降级为旧输出 + if ctx.Runtime != nil && ctx.MessageID != "" { + if tree := fetchFolderChildrenTree(ctx.Runtime, key, ctx.MessageID); tree != "" { + return tree + } + } if name != "" { return fmt.Sprintf(``, cardEscapeAttr(key), cardEscapeAttr(name)) } return fmt.Sprintf(``, cardEscapeAttr(key)) } +// fetchFolderChildrenTree 调 openapi 展开文件夹一层,返回树形文本(含 children_count 深层提示)。 +// 失败时返回空串,由调用方降级为旧输出。 +func fetchFolderChildrenTree(runtime *common.RuntimeContext, folderKey, messageID string) string { + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/resources/"+folderKey+"/children", + larkcore.QueryParams{ + "srctype": []string{"message"}, + "srcid": []string{messageID}, + "recursive": []string{"false"}, + }, nil) + if err != nil || data == nil { + return "" + } + rawItems, _ := data["items"].([]interface{}) + if len(rawItems) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "\n", cardEscapeAttr(folderKey)) + for _, raw := range rawItems { + item, _ := raw.(map[string]interface{}) + k, _ := item["file_key"].(string) + n, _ := item["name"].(string) + isFolder, _ := item["is_folder"].(bool) + if isFolder { + cc, _ := item["children_count"].(float64) + fmt.Fprintf(&b, " - [dir] %s (%s) children_count=%d [可 recursive=true 展开更深层]\n", + cardEscapeAttr(n), cardEscapeAttr(k), int64(cc)) + } else { + fmt.Fprintf(&b, " - %s (%s)\n", cardEscapeAttr(n), cardEscapeAttr(k)) + } + } + b.WriteString("") + return b.String() +} + type calendarEventConverter struct{} // Convert converts a share_calendar_event message content JSON to human-readable string. From 70f3b6249f35771c7f987543f2f5573448d8ec2a Mon Sep 17 00:00:00 2001 From: jackie3927 Date: Sat, 29 Aug 2026 19:27:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20folder=20children=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=8A=A0=20folder=20=E8=AF=AD=E4=B9=89=20/resources/f?= =?UTF-8?q?older/:file=5Fkey/children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/im/convert_lib/misc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/im/convert_lib/misc.go b/shortcuts/im/convert_lib/misc.go index 5ee6e67a77..3c309b6f4e 100644 --- a/shortcuts/im/convert_lib/misc.go +++ b/shortcuts/im/convert_lib/misc.go @@ -93,7 +93,7 @@ func (folderConverter) Convert(ctx *ConvertContext) string { // fetchFolderChildrenTree 调 openapi 展开文件夹一层,返回树形文本(含 children_count 深层提示)。 // 失败时返回空串,由调用方降级为旧输出。 func fetchFolderChildrenTree(runtime *common.RuntimeContext, folderKey, messageID string) string { - data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/resources/"+folderKey+"/children", + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/resources/folder/"+folderKey+"/children", larkcore.QueryParams{ "srctype": []string{"message"}, "srcid": []string{messageID}, From 0849b2fe34290d832620ccd10ef8745565b8cad2 Mon Sep 17 00:00:00 2001 From: jackie3927 Date: Wed, 2 Sep 2026 13:54:39 +0800 Subject: [PATCH 3/3] feat: folder expand via /files/:file_key/folder (11050 endpoint) with XML output + unit tests; debug transport NOT included --- shortcuts/im/convert_lib/folder_test.go | 102 ++++++++++++++++++++++++ shortcuts/im/convert_lib/misc.go | 54 +++++++++++-- 2 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 shortcuts/im/convert_lib/folder_test.go diff --git a/shortcuts/im/convert_lib/folder_test.go b/shortcuts/im/convert_lib/folder_test.go new file mode 100644 index 0000000000..bb00a7db29 --- /dev/null +++ b/shortcuts/im/convert_lib/folder_test.go @@ -0,0 +1,102 @@ +package convertlib + +// C4-C6:fetchFolderChildrenTree 单测(mock httpmock,不依赖真实 openapi) +// 覆盖 XML 一层输出(folder name+key+child_count / file name+key / 子文件夹 child_count / has_more) + +import ( + "context" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func folderTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser) + return rt, reg +} + +// C4:正常展开一层(文件 + 子文件夹 + child_count),无 has_more(items == all_count) +func TestFetchFolderChildrenTree_XMLOneLevel(t *testing.T) { + rt, reg := folderTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/im/v1/files/fld_root/folder", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"file_key": "f1", "name": "报告.pdf", "is_folder": false}, + map[string]interface{}{"file_key": "f2", "name": "文档.docx", "is_folder": false}, + map[string]interface{}{"file_key": "f3", "name": "子文件夹", "is_folder": true, "children_count": float64(3)}, + }, + "all_count": float64(3), + }, + }, + }) + got := fetchFolderChildrenTree(rt, "fld_root", "tmpavatra", "om_123") + want := `` + if got != want { + t.Fatalf("fetchFolderChildrenTree() = %q, want %q", got, want) + } +} + +// C4b:items < all_count 时根 folder 带 has_more="true" +func TestFetchFolderChildrenTree_HasMore(t *testing.T) { + rt, reg := folderTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/im/v1/files/fld_root/folder", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"file_key": "f1", "name": "a.pdf", "is_folder": false}, + }, + "all_count": float64(100), + }, + }, + }) + got := fetchFolderChildrenTree(rt, "fld_root", "big", "om_123") + want := `` + if got != want { + t.Fatalf("fetchFolderChildrenTree() = %q, want %q", got, want) + } +} + +// C5:API 失败(error/nil)→ 返回空串(调用方降级旧输出) +func TestFetchFolderChildrenTree_APIFailure(t *testing.T) { + rt, reg := folderTestRuntime(t) + // 不注册 stub → httpmock 返回错误 + got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123") + if got != "" { + t.Fatalf("fetchFolderChildrenTree() on API failure = %q, want empty (caller downgrades)", got) + } + _ = reg +} + +// C6:items 空 → 返回空串(降级) +func TestFetchFolderChildrenTree_EmptyItems(t *testing.T) { + rt, reg := folderTestRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/im/v1/files/fld_root/folder", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{}, + "all_count": float64(0), + }, + }, + }) + got := fetchFolderChildrenTree(rt, "fld_root", "x", "om_123") + if got != "" { + t.Fatalf("fetchFolderChildrenTree() empty items = %q, want empty", got) + } +} diff --git a/shortcuts/im/convert_lib/misc.go b/shortcuts/im/convert_lib/misc.go index 3c309b6f4e..25dc21a834 100644 --- a/shortcuts/im/convert_lib/misc.go +++ b/shortcuts/im/convert_lib/misc.go @@ -4,6 +4,7 @@ package convertlib import ( + "encoding/json" "fmt" "net/http" "regexp" @@ -80,7 +81,7 @@ func (folderConverter) Convert(ctx *ConvertContext) string { // 展开一层:调 openapi children(recursive=false),输出第一层 + children_count + 深层提示 // 需要 Runtime + MessageID(srctype=message&srcid=MessageID);不可用时降级为旧输出 if ctx.Runtime != nil && ctx.MessageID != "" { - if tree := fetchFolderChildrenTree(ctx.Runtime, key, ctx.MessageID); tree != "" { + if tree := fetchFolderChildrenTree(ctx.Runtime, key, name, ctx.MessageID); tree != "" { return tree } } @@ -92,8 +93,8 @@ func (folderConverter) Convert(ctx *ConvertContext) string { // fetchFolderChildrenTree 调 openapi 展开文件夹一层,返回树形文本(含 children_count 深层提示)。 // 失败时返回空串,由调用方降级为旧输出。 -func fetchFolderChildrenTree(runtime *common.RuntimeContext, folderKey, messageID string) string { - data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/resources/folder/"+folderKey+"/children", +func fetchFolderChildrenTree(runtime *common.RuntimeContext, folderKey, folderName, messageID string) string { + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/files/"+folderKey+"/folder", larkcore.QueryParams{ "srctype": []string{"message"}, "srcid": []string{messageID}, @@ -106,25 +107,62 @@ func fetchFolderChildrenTree(runtime *common.RuntimeContext, folderKey, messageI if len(rawItems) == 0 { return "" } + // 只展开一层:file 用 ;子文件夹用 (不递归,child_count 提示深层) + // 根 folder 带 child_count(=all_count 子项总数)+ has_more(items 数 < all_count 时标注还有更多未展示) + hasMore := false + var allCount int64 + if v, ok := data["all_count"]; ok { + allCount = numToInt64(v) + if allCount > int64(len(rawItems)) { + hasMore = true + } + } var b strings.Builder - fmt.Fprintf(&b, "\n", cardEscapeAttr(folderKey)) + b.WriteString(` 0 { + fmt.Fprintf(&b, ` child_count="%d"`, allCount) + } + if hasMore { + b.WriteString(` has_more="true"`) + } + b.WriteString(`>`) for _, raw := range rawItems { item, _ := raw.(map[string]interface{}) k, _ := item["file_key"].(string) n, _ := item["name"].(string) isFolder, _ := item["is_folder"].(bool) if isFolder { - cc, _ := item["children_count"].(float64) - fmt.Fprintf(&b, " - [dir] %s (%s) children_count=%d [可 recursive=true 展开更深层]\n", - cardEscapeAttr(n), cardEscapeAttr(k), int64(cc)) + cc := numToInt64(item["children_count"]) + fmt.Fprintf(&b, ``, + cardEscapeAttr(n), cardEscapeAttr(k), cc) } else { - fmt.Fprintf(&b, " - %s (%s)\n", cardEscapeAttr(n), cardEscapeAttr(k)) + fmt.Fprintf(&b, ``, cardEscapeAttr(n), cardEscapeAttr(k)) } } b.WriteString("") return b.String() } + +// numToInt64 兼容 JSON number(json.Number)/ float64 / int 的类型转换。 +func numToInt64(v interface{}) int64 { + switch n := v.(type) { + case json.Number: + if i, err := n.Int64(); err == nil { + return i + } + case float64: + return int64(n) + case float32: + return int64(n) + case int: + return int64(n) + case int64: + return n + } + return 0 +} + type calendarEventConverter struct{} // Convert converts a share_calendar_event message content JSON to human-readable string.