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 15a9f1f378..25dc21a834 100644
--- a/shortcuts/im/convert_lib/misc.go
+++ b/shortcuts/im/convert_lib/misc.go
@@ -4,9 +4,14 @@
package convertlib
import (
+ "encoding/json"
"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 +77,92 @@ 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, name, 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, 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},
+ "recursive": []string{"false"},
+ }, nil)
+ if err != nil || data == nil {
+ return ""
+ }
+ rawItems, _ := data["items"].([]interface{})
+ 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
+ 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 := numToInt64(item["children_count"])
+ fmt.Fprintf(&b, ``,
+ cardEscapeAttr(n), cardEscapeAttr(k), cc)
+ } else {
+ 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.