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.